@opencraw/core 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/access/access-broker.use-case.d.ts +43 -0
- package/dist/access/access-config.error.d.ts +5 -0
- package/dist/access/access-config.repository.d.ts +11 -0
- package/dist/access/access-json-schema.mapper.d.ts +8 -0
- package/dist/access/access-plugin.contract.d.ts +48 -0
- package/dist/access/access-preset.store.d.ts +19 -0
- package/dist/access/access-profile.contract.d.ts +95 -0
- package/dist/access/index.d.ts +11 -0
- package/dist/access/render-access.mapper.d.ts +41 -0
- package/dist/access/session-id.algorithm.d.ts +8 -0
- package/dist/api-steps/extract-from-document.use-case.d.ts +41 -0
- package/dist/api-steps/index.d.ts +4 -0
- package/dist/api-steps/run-api-step.use-case.d.ts +20 -0
- package/dist/api-steps/send-request.use-case.d.ts +23 -0
- package/dist/browser-session/browser-profile.store.d.ts +52 -0
- package/dist/browser-session/browser-session.config.d.ts +21 -0
- package/dist/browser-session/browser.client.d.ts +75 -0
- package/dist/browser-session/index.d.ts +6 -0
- package/dist/captcha/captcha-budget.model.d.ts +21 -0
- package/dist/captcha/captcha-detection.client.d.ts +28 -0
- package/dist/captcha/captcha-guard.use-case.d.ts +64 -0
- package/dist/captcha/captcha-solver-registry.store.d.ts +19 -0
- package/dist/captcha/captcha-solver.contract.d.ts +47 -0
- package/dist/captcha/captcha.error.d.ts +13 -0
- package/dist/captcha/index.d.ts +10 -0
- package/dist/captcha/resolve-captcha.use-case.d.ts +40 -0
- package/dist/crawl-events/crawl-event.contract.d.ts +169 -0
- package/dist/crawl-events/event-bus.store.d.ts +10 -0
- package/dist/crawl-events/index.d.ts +4 -0
- package/dist/crawl-events/trace-line.mapper.d.ts +14 -0
- package/dist/crawl-execution/bootstrap-session.use-case.d.ts +79 -0
- package/dist/crawl-execution/crawl-options.config.d.ts +67 -0
- package/dist/crawl-execution/crawl-report.model.d.ts +33 -0
- package/dist/crawl-execution/create-crawler.use-case.d.ts +20 -0
- package/dist/crawl-execution/index.d.ts +8 -0
- package/dist/crawl-execution/rotating-runner.use-case.d.ts +66 -0
- package/dist/crawl-execution/run-crawl.use-case.d.ts +17 -0
- package/dist/crawl-execution/run-input-recipe.use-case.d.ts +51 -0
- package/dist/deck-document/deck-document.model.d.ts +58 -0
- package/dist/deck-document/deck-table.algorithm.d.ts +35 -0
- package/dist/deck-document/index.d.ts +6 -0
- package/dist/deck-document/read-pptx.client.d.ts +16 -0
- package/dist/docx-document/index.d.ts +2 -0
- package/dist/docx-document/read-docx-html.client.d.ts +23 -0
- package/dist/extraction-scope/extraction-scope.model.d.ts +77 -0
- package/dist/extraction-scope/index.d.ts +5 -0
- package/dist/extraction-scope/live-element.model.d.ts +28 -0
- package/dist/hooks/hook-registry.store.d.ts +16 -0
- package/dist/hooks/hook.contract.d.ts +17 -0
- package/dist/hooks/hook.error.d.ts +7 -0
- package/dist/hooks/index.d.ts +4 -0
- package/dist/http-session/http-response.contract.d.ts +58 -0
- package/dist/http-session/http.client.d.ts +42 -0
- package/dist/http-session/index.d.ts +5 -0
- package/dist/http-session/text-decoding.algorithm.d.ts +35 -0
- package/dist/index.esm.js +1410 -95
- package/dist/markdown-document/index.d.ts +3 -0
- package/dist/markdown-document/read-markdown.client.d.ts +39 -0
- package/dist/output-mapping/coerce-field.mapper.d.ts +19 -0
- package/dist/output-mapping/generated-field.mapper.d.ts +16 -0
- package/dist/output-mapping/index.d.ts +9 -0
- package/dist/output-mapping/map-record.use-case.d.ts +27 -0
- package/dist/output-mapping/mapping.error.d.ts +17 -0
- package/dist/output-mapping/missing-value.policy.d.ts +14 -0
- package/dist/output-mapping/output-field.validator.d.ts +10 -0
- package/dist/output-mapping/output-record.model.d.ts +20 -0
- package/dist/pdf-document/index.d.ts +7 -0
- package/dist/pdf-document/pdf-document.model.d.ts +51 -0
- package/dist/pdf-document/pdf-table.algorithm.d.ts +43 -0
- package/dist/pdf-document/read-pdf.client.d.ts +18 -0
- package/dist/pdf-document/row-assembly.algorithm.d.ts +26 -0
- package/dist/recipe-loading/index.d.ts +9 -0
- package/dist/recipe-loading/load-recipe-set.use-case.d.ts +45 -0
- package/dist/recipe-loading/read-recipe-source.use-case.d.ts +14 -0
- package/dist/recipe-loading/recipe-binding.error.d.ts +14 -0
- package/dist/recipe-loading/recipe-binding.validator.d.ts +34 -0
- package/dist/recipe-loading/recipe-file.repository.d.ts +11 -0
- package/dist/recipe-loading/recipe-set.model.d.ts +12 -0
- package/dist/recipe-loading/recipe-source.contract.d.ts +28 -0
- package/dist/recipe-loading/recipe-text.mapper.d.ts +14 -0
- package/dist/recipe-schema/index.d.ts +15 -0
- package/dist/recipe-schema/input-recipe.contract.d.ts +156 -0
- package/dist/recipe-schema/json-schema.mapper.d.ts +15 -0
- package/dist/recipe-schema/output-recipe.contract.d.ts +47 -0
- package/dist/recipe-schema/recipe-kind.enum.d.ts +35 -0
- package/dist/recipe-schema/recipe-validation.error.d.ts +17 -0
- package/dist/recipe-schema/recipe.validator.d.ts +28 -0
- package/dist/recipe-schema/step.contract.d.ts +206 -0
- package/dist/recipe-schema/transform-rule.contract.d.ts +120 -0
- package/dist/record-diff/index.d.ts +4 -0
- package/dist/record-diff/record-diff.algorithm.d.ts +82 -0
- package/dist/record-diff/records-file.repository.d.ts +10 -0
- package/dist/record-sink/dedupe.policy.d.ts +29 -0
- package/dist/record-sink/index.d.ts +9 -0
- package/dist/record-sink/json-lines-sink.repository.d.ts +19 -0
- package/dist/record-sink/memory-sink.repository.d.ts +9 -0
- package/dist/record-sink/record-sink.contract.d.ts +17 -0
- package/dist/selection/html-selector.algorithm.d.ts +19 -0
- package/dist/selection/index.d.ts +8 -0
- package/dist/selection/json-path.algorithm.d.ts +9 -0
- package/dist/selection/json-text.algorithm.d.ts +58 -0
- package/dist/selection/regex.algorithm.d.ts +12 -0
- package/dist/selection/take-value.mapper.d.ts +27 -0
- package/dist/src/access/access-profile.contract.d.ts +4 -0
- package/dist/src/access/index.d.ts +1 -1
- package/dist/src/api-steps/extract-from-document.use-case.d.ts +5 -5
- package/dist/src/api-steps/send-request.use-case.d.ts +3 -2
- package/dist/src/browser-session/browser-profile.store.d.ts +52 -0
- package/dist/src/browser-session/browser.client.d.ts +8 -0
- package/dist/src/browser-session/index.d.ts +1 -0
- package/dist/src/crawl-events/crawl-event.contract.d.ts +8 -0
- package/dist/src/crawl-execution/bootstrap-session.use-case.d.ts +23 -2
- package/dist/src/crawl-execution/crawl-options.config.d.ts +27 -0
- package/dist/src/crawl-execution/rotating-runner.use-case.d.ts +9 -0
- package/dist/src/crawl-execution/run-crawl.use-case.d.ts +6 -2
- package/dist/src/crawl-execution/run-input-recipe.use-case.d.ts +11 -3
- package/dist/src/docx-document/index.d.ts +2 -0
- package/dist/src/docx-document/read-docx-html.client.d.ts +23 -0
- package/dist/src/extraction-scope/extraction-scope.model.d.ts +2 -1
- package/dist/src/http-session/http-response.contract.d.ts +2 -1
- package/dist/src/index.d.ts +9 -4
- package/dist/src/markdown-document/index.d.ts +1 -1
- package/dist/src/markdown-document/read-markdown.client.d.ts +10 -0
- package/dist/src/recipe-schema/index.d.ts +2 -2
- package/dist/src/recipe-schema/input-recipe.contract.d.ts +24 -1
- package/dist/src/recipe-schema/recipe-kind.enum.d.ts +1 -1
- package/dist/src/recipe-schema/step.contract.d.ts +4 -0
- package/dist/src/record-diff/index.d.ts +4 -0
- package/dist/src/record-diff/record-diff.algorithm.d.ts +82 -0
- package/dist/src/record-diff/records-file.repository.d.ts +10 -0
- package/dist/src/record-sink/dedupe.policy.d.ts +19 -7
- package/dist/src/record-sink/index.d.ts +1 -0
- package/dist/src/selection/html-selector.algorithm.d.ts +2 -1
- package/dist/src/step-flow/for-each.use-case.d.ts +4 -2
- package/dist/src/step-flow/host-throttle.policy.d.ts +49 -0
- package/dist/src/step-flow/index.d.ts +5 -0
- package/dist/src/step-flow/run-gate.policy.d.ts +12 -1
- package/dist/src/step-flow/step-runner.contract.d.ts +13 -0
- package/dist/src/step-flow/transport-retry.policy.d.ts +76 -0
- package/dist/src/web-steps/navigate.use-case.d.ts +3 -2
- package/dist/src/web-steps/run-web-step.use-case.d.ts +8 -1
- package/dist/src/xml-document/index.d.ts +6 -0
- package/dist/src/xml-document/xml-document.model.d.ts +13 -0
- package/dist/src/xml-document/xml-parser.client.d.ts +49 -0
- package/dist/src/xml-document/xpath.algorithm.d.ts +30 -0
- package/dist/step-flow/block-rule.policy.d.ts +21 -0
- package/dist/step-flow/blocked.error.d.ts +9 -0
- package/dist/step-flow/for-each.use-case.d.ts +20 -0
- package/dist/step-flow/host-throttle.policy.d.ts +49 -0
- package/dist/step-flow/index.d.ts +15 -0
- package/dist/step-flow/paginate.use-case.d.ts +17 -0
- package/dist/step-flow/retry.policy.d.ts +21 -0
- package/dist/step-flow/run-gate.policy.d.ts +50 -0
- package/dist/step-flow/run-steps.use-case.d.ts +39 -0
- package/dist/step-flow/step-failure.error.d.ts +14 -0
- package/dist/step-flow/step-runner.contract.d.ts +52 -0
- package/dist/step-flow/transport-retry.policy.d.ts +76 -0
- package/dist/template/expression.algorithm.d.ts +53 -0
- package/dist/template/index.d.ts +7 -0
- package/dist/template/template.algorithm.d.ts +53 -0
- package/dist/template/value-path.algorithm.d.ts +29 -0
- package/dist/template/value-text.algorithm.d.ts +18 -0
- package/dist/transformation/apply-transform-chain.use-case.d.ts +17 -0
- package/dist/transformation/collection.algorithm.d.ts +15 -0
- package/dist/transformation/currency.algorithm.d.ts +15 -0
- package/dist/transformation/date.algorithm.d.ts +13 -0
- package/dist/transformation/index.d.ts +10 -0
- package/dist/transformation/lookup.algorithm.d.ts +24 -0
- package/dist/transformation/number.algorithm.d.ts +30 -0
- package/dist/transformation/string.algorithm.d.ts +18 -0
- package/dist/transformation/transform-registry.store.d.ts +36 -0
- package/dist/transformation/transform.error.d.ts +8 -0
- package/dist/transformation/url.algorithm.d.ts +20 -0
- package/dist/web-steps/evaluate-script.use-case.d.ts +10 -0
- package/dist/web-steps/extract-from-page.use-case.d.ts +10 -0
- package/dist/web-steps/index.d.ts +2 -0
- package/dist/web-steps/interact.use-case.d.ts +36 -0
- package/dist/web-steps/navigate.use-case.d.ts +15 -0
- package/dist/web-steps/run-web-step.use-case.d.ts +37 -0
- package/dist/web-steps/snapshot-elements.use-case.d.ts +13 -0
- package/dist/workbook-document/csv-parser.algorithm.d.ts +26 -0
- package/dist/workbook-document/csv-workbook.mapper.d.ts +24 -0
- package/dist/workbook-document/grid-table.algorithm.d.ts +53 -0
- package/dist/workbook-document/html-tables.mapper.d.ts +14 -0
- package/dist/workbook-document/index.d.ts +9 -0
- package/dist/workbook-document/read-xlsx.client.d.ts +18 -0
- package/dist/workbook-document/workbook-document.model.d.ts +51 -0
- package/dist/xml-document/index.d.ts +6 -0
- package/dist/xml-document/xml-document.model.d.ts +13 -0
- package/dist/xml-document/xml-parser.client.d.ts +49 -0
- package/dist/xml-document/xpath.algorithm.d.ts +30 -0
- package/dist/yaml-document/index.d.ts +3 -0
- package/dist/yaml-document/read-yaml.client.d.ts +25 -0
- package/package.json +4 -2
package/dist/index.esm.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { randomInt, randomUUID } from 'node:crypto';
|
|
3
|
-
import { readFile, mkdir, writeFile, stat, readdir } from 'node:fs/promises';
|
|
4
|
-
import { resolve as resolve$1,
|
|
3
|
+
import { readFile, mkdir, writeFile, rm, stat, readdir } from 'node:fs/promises';
|
|
4
|
+
import { resolve as resolve$1, join as join$1, dirname, extname } from 'node:path';
|
|
5
5
|
import { firefox, webkit, chromium, request } from 'playwright';
|
|
6
6
|
import { createWriteStream } from 'node:fs';
|
|
7
7
|
import { once } from 'node:events';
|
|
8
8
|
import { JSONPath } from 'jsonpath-plus';
|
|
9
9
|
import { load } from 'cheerio';
|
|
10
|
+
import { gunzipSync } from 'node:zlib';
|
|
11
|
+
import { DOMParser, DOMImplementation, XMLSerializer } from '@xmldom/xmldom';
|
|
12
|
+
import { useNamespaces } from 'xpath';
|
|
10
13
|
import { fileURLToPath } from 'node:url';
|
|
11
14
|
|
|
12
15
|
/** Resource types a page may skip loading, to save bandwidth on per-GB proxies. */
|
|
@@ -67,10 +70,20 @@ const pluginSchema = z.strictObject({
|
|
|
67
70
|
options: z.record(z.string(), z.unknown()).optional()
|
|
68
71
|
});
|
|
69
72
|
const accessProfileSchema = z.union([directSchema, proxySchema, poolSchema, cdpSchema, pluginSchema]);
|
|
73
|
+
const hostRuleSchema = z.strictObject({
|
|
74
|
+
delayMs: z.int().nonnegative().optional(),
|
|
75
|
+
concurrency: z.int().min(1).max(256).optional()
|
|
76
|
+
});
|
|
77
|
+
const throttleConfigSchema = z.strictObject({
|
|
78
|
+
delayMs: z.int().nonnegative().optional(),
|
|
79
|
+
concurrency: z.int().min(1).max(256).optional(),
|
|
80
|
+
domains: z.record(z.string().regex(/^[\w.-]+$/, 'a domain such as example.com'), hostRuleSchema).optional()
|
|
81
|
+
});
|
|
70
82
|
const accessConfigSchema = z.strictObject({
|
|
71
83
|
$schema: z.string().optional(),
|
|
72
84
|
profiles: z.record(z.string().regex(/^[\w-]+$/, 'a profile name is letters, digits, hyphens and underscores'), accessProfileSchema),
|
|
73
|
-
default: z.string().optional()
|
|
85
|
+
default: z.string().optional(),
|
|
86
|
+
throttle: throttleConfigSchema.optional()
|
|
74
87
|
}).refine(config => config.default === undefined || Object.hasOwn(config.profiles, config.default), {
|
|
75
88
|
message: 'default names a profile that does not exist',
|
|
76
89
|
path: ['default']
|
|
@@ -1179,6 +1192,17 @@ async function blockResources(context, types) {
|
|
|
1179
1192
|
await route.continue();
|
|
1180
1193
|
});
|
|
1181
1194
|
}
|
|
1195
|
+
/**
|
|
1196
|
+
* What a context gets after it opened: the cookies to add and the resource
|
|
1197
|
+
* types to skip.
|
|
1198
|
+
*
|
|
1199
|
+
* @param context - The context.
|
|
1200
|
+
* @param options - The session options.
|
|
1201
|
+
*/
|
|
1202
|
+
async function applySessionExtras(context, options) {
|
|
1203
|
+
if (options.cookies !== undefined && options.cookies.length > 0) await context.addCookies(options.cookies);
|
|
1204
|
+
if (options.blockResources !== undefined && options.blockResources.length > 0) await blockResources(context, new Set(options.blockResources));
|
|
1205
|
+
}
|
|
1182
1206
|
/** A launched browser; sessions are opened from it and closed independently. */
|
|
1183
1207
|
class BrowserClient {
|
|
1184
1208
|
browser;
|
|
@@ -1238,8 +1262,7 @@ class BrowserClient {
|
|
|
1238
1262
|
};
|
|
1239
1263
|
const context = await this.browser.newContext(contextOptions);
|
|
1240
1264
|
if (this.config.timeoutMs !== undefined) context.setDefaultTimeout(this.config.timeoutMs);
|
|
1241
|
-
|
|
1242
|
-
if (options.blockResources !== undefined && options.blockResources.length > 0) await blockResources(context, new Set(options.blockResources));
|
|
1265
|
+
await applySessionExtras(context, options);
|
|
1243
1266
|
const page = await context.newPage();
|
|
1244
1267
|
return new BrowserSession(context, page);
|
|
1245
1268
|
}
|
|
@@ -1248,13 +1271,206 @@ class BrowserClient {
|
|
|
1248
1271
|
}
|
|
1249
1272
|
}
|
|
1250
1273
|
|
|
1274
|
+
/** The file that marks a profile as in use, holding the owning process id. */
|
|
1275
|
+
const LOCK_FILE = '.opencraw.lock';
|
|
1276
|
+
/** A browser profile name: it becomes a directory, so no separators or dots. */
|
|
1277
|
+
const BROWSER_PROFILE_NAME = /^[\w-]+$/;
|
|
1278
|
+
/**
|
|
1279
|
+
* Browser profiles that persist between runs: each is a directory of a real
|
|
1280
|
+
* browser's user data (cookies, local storage, IndexedDB, cache, service
|
|
1281
|
+
* workers), so a login, a consent choice or a site's trust in a returning
|
|
1282
|
+
* visitor carries over to the next run. The browser equivalent of a user who
|
|
1283
|
+
* never clears their history.
|
|
1284
|
+
*
|
|
1285
|
+
* A profile directory can be open in one browser at a time. Within this
|
|
1286
|
+
* crawler, a second use waits for the first to close; the same owner (one
|
|
1287
|
+
* recipe run reopening after a rotation) takes it over instead. Another
|
|
1288
|
+
* crawler holding it, in this process or another, is reported, not waited
|
|
1289
|
+
* for: a lock file in the profile names the process, and one left by a
|
|
1290
|
+
* process that died is taken over. (Chromium's own profile lock is not
|
|
1291
|
+
* enough: headless builds do not take it.)
|
|
1292
|
+
*/
|
|
1293
|
+
class BrowserProfiles {
|
|
1294
|
+
directory;
|
|
1295
|
+
config;
|
|
1296
|
+
held = new Map();
|
|
1297
|
+
waiting = new Map();
|
|
1298
|
+
/**
|
|
1299
|
+
* @param directory - Where the profiles live, one subdirectory each.
|
|
1300
|
+
* @param config - The crawler's browser settings (type, binary, headless, timeouts).
|
|
1301
|
+
*/
|
|
1302
|
+
constructor(directory, config = {}) {
|
|
1303
|
+
this.directory = directory;
|
|
1304
|
+
this.config = config;
|
|
1305
|
+
}
|
|
1306
|
+
async take(name, owner) {
|
|
1307
|
+
for (;;) {
|
|
1308
|
+
const holder = this.held.get(name);
|
|
1309
|
+
if (holder === undefined) return;
|
|
1310
|
+
if (holder.owner === owner) {
|
|
1311
|
+
await holder.session.close();
|
|
1312
|
+
continue;
|
|
1313
|
+
}
|
|
1314
|
+
await new Promise(resolve => {
|
|
1315
|
+
const queue = this.waiting.get(name) ?? [];
|
|
1316
|
+
queue.push(resolve);
|
|
1317
|
+
this.waiting.set(name, queue);
|
|
1318
|
+
});
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
free(name) {
|
|
1322
|
+
this.held.delete(name);
|
|
1323
|
+
this.waiting.get(name)?.shift()?.();
|
|
1324
|
+
}
|
|
1325
|
+
launch(path, options) {
|
|
1326
|
+
const type = this.config.browserType ?? DEFAULT_BROWSER_CONFIG.browserType;
|
|
1327
|
+
const launcher = type === 'firefox' ? firefox : type === 'webkit' ? webkit : chromium;
|
|
1328
|
+
return launcher.launchPersistentContext(path, {
|
|
1329
|
+
headless: this.config.headless ?? DEFAULT_BROWSER_CONFIG.headless,
|
|
1330
|
+
slowMo: this.config.slowMo,
|
|
1331
|
+
executablePath: this.config.executablePath,
|
|
1332
|
+
proxy: options.proxy ?? this.config.proxy,
|
|
1333
|
+
extraHTTPHeaders: options.headers,
|
|
1334
|
+
userAgent: options.userAgent,
|
|
1335
|
+
viewport: options.viewport,
|
|
1336
|
+
ignoreHTTPSErrors: this.config.ignoreHTTPSErrors === true || options.ignoreHTTPSErrors === true
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
/**
|
|
1340
|
+
* The profile's directory.
|
|
1341
|
+
*
|
|
1342
|
+
* @param name - A profile name.
|
|
1343
|
+
* @returns The absolute path.
|
|
1344
|
+
*/
|
|
1345
|
+
pathOf(name) {
|
|
1346
|
+
if (!BROWSER_PROFILE_NAME.test(name)) throw new Error(`browser profile "${name}": a name is letters, digits, hyphens and underscores`);
|
|
1347
|
+
return resolve$1(this.directory, name);
|
|
1348
|
+
}
|
|
1349
|
+
/**
|
|
1350
|
+
* Opens a profile in its own browser, waiting while another run of this
|
|
1351
|
+
* crawler uses it.
|
|
1352
|
+
*
|
|
1353
|
+
* @param name - The profile.
|
|
1354
|
+
* @param options - Proxy, headers, viewport, cookies to add. `storageState` is ignored: the profile has its own.
|
|
1355
|
+
* @param owner - Who opens it; the same owner reopening closes its previous session first.
|
|
1356
|
+
* @returns The session; closing it frees the profile.
|
|
1357
|
+
*/
|
|
1358
|
+
async open(name, options, owner) {
|
|
1359
|
+
const path = this.pathOf(name);
|
|
1360
|
+
await this.take(name, owner);
|
|
1361
|
+
let context;
|
|
1362
|
+
let unlock = unlocked;
|
|
1363
|
+
try {
|
|
1364
|
+
await mkdir(path, {
|
|
1365
|
+
recursive: true
|
|
1366
|
+
});
|
|
1367
|
+
unlock = await lockProfile(path, name);
|
|
1368
|
+
context = await this.launch(path, options);
|
|
1369
|
+
} catch (error) {
|
|
1370
|
+
await unlock();
|
|
1371
|
+
this.free(name);
|
|
1372
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1373
|
+
if (/ProcessSingleton|SingletonLock|already in use/i.test(message)) throw new Error(`browser profile "${name}" is open in another browser (${path}); close it or use another profile`, {
|
|
1374
|
+
cause: error
|
|
1375
|
+
});
|
|
1376
|
+
throw error;
|
|
1377
|
+
}
|
|
1378
|
+
if (this.config.timeoutMs !== undefined) context.setDefaultTimeout(this.config.timeoutMs);
|
|
1379
|
+
await applySessionExtras(context, options);
|
|
1380
|
+
const page = context.pages()[0] ?? (await context.newPage());
|
|
1381
|
+
let closed = false;
|
|
1382
|
+
const session = new BrowserSession(context, page, async () => {
|
|
1383
|
+
if (closed) return;
|
|
1384
|
+
closed = true;
|
|
1385
|
+
try {
|
|
1386
|
+
await context.close();
|
|
1387
|
+
} finally {
|
|
1388
|
+
await unlock();
|
|
1389
|
+
this.free(name);
|
|
1390
|
+
}
|
|
1391
|
+
});
|
|
1392
|
+
this.held.set(name, {
|
|
1393
|
+
owner,
|
|
1394
|
+
session
|
|
1395
|
+
});
|
|
1396
|
+
return session;
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
/**
|
|
1400
|
+
* Marks a profile as used by this process, refusing one a live process
|
|
1401
|
+
* holds and taking over one a dead process left behind.
|
|
1402
|
+
*
|
|
1403
|
+
* @param path - The profile directory.
|
|
1404
|
+
* @param name - The profile name, for the message.
|
|
1405
|
+
* @returns The unlock.
|
|
1406
|
+
* @throws Error when another crawler holds the profile.
|
|
1407
|
+
*/
|
|
1408
|
+
async function lockProfile(path, name) {
|
|
1409
|
+
const file = join$1(path, LOCK_FILE);
|
|
1410
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1411
|
+
try {
|
|
1412
|
+
await writeFile(file, String(process.pid), {
|
|
1413
|
+
flag: 'wx'
|
|
1414
|
+
});
|
|
1415
|
+
return async () => {
|
|
1416
|
+
await rm(file, {
|
|
1417
|
+
force: true
|
|
1418
|
+
});
|
|
1419
|
+
};
|
|
1420
|
+
} catch (error) {
|
|
1421
|
+
if (error.code !== 'EEXIST') throw error;
|
|
1422
|
+
const holder = Number(await readHolder(file));
|
|
1423
|
+
if (Number.isSafeInteger(holder) && holder > 0 && isAlive(holder)) throw new Error(`browser profile "${name}" is open in another browser (${path}, process ${holder}); close it or use another profile`, {
|
|
1424
|
+
cause: error
|
|
1425
|
+
});
|
|
1426
|
+
await rm(file, {
|
|
1427
|
+
force: true
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
throw new Error(`browser profile "${name}": cannot lock ${path}`);
|
|
1432
|
+
}
|
|
1433
|
+
/** The unlock of a profile that was never locked. */
|
|
1434
|
+
async function unlocked() {}
|
|
1435
|
+
async function readHolder(file) {
|
|
1436
|
+
try {
|
|
1437
|
+
return await readFile(file, 'utf8');
|
|
1438
|
+
} catch {
|
|
1439
|
+
return '';
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
function isAlive(pid) {
|
|
1443
|
+
try {
|
|
1444
|
+
process.kill(pid, 0);
|
|
1445
|
+
return true;
|
|
1446
|
+
} catch (error) {
|
|
1447
|
+
return error.code === 'EPERM';
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
/**
|
|
1452
|
+
* Disposes a runner, ignoring a failure: a tab whose browser already went
|
|
1453
|
+
* away (a rotation, a crash) has nothing left to close.
|
|
1454
|
+
*
|
|
1455
|
+
* @param runner - The runner.
|
|
1456
|
+
*/
|
|
1457
|
+
async function disposeQuietly(runner) {
|
|
1458
|
+
try {
|
|
1459
|
+
await runner.dispose();
|
|
1460
|
+
} catch {
|
|
1461
|
+
// already gone
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1251
1465
|
/**
|
|
1252
1466
|
* Runs a body once per item of a list (`over`), or once per live element
|
|
1253
1467
|
* matching `selector`, each in a fresh child scope with the item bound under
|
|
1254
1468
|
* `as`; emits a record per iteration when asked.
|
|
1255
1469
|
*
|
|
1256
|
-
* With a concurrent gate, iterations run as permits allow and
|
|
1257
|
-
* out in completion order; without one, in list order.
|
|
1470
|
+
* With a concurrent gate, iterations of a list run as permits allow and
|
|
1471
|
+
* records come out in completion order; without one, in list order. In web
|
|
1472
|
+
* mode each parallel iteration runs in a tab of its own (`runner.fork`); a
|
|
1473
|
+
* loop over live elements stays sequential, since its elements live on one page.
|
|
1258
1474
|
*
|
|
1259
1475
|
* @param step - The forEach step.
|
|
1260
1476
|
* @param scope - The scope the list lives in.
|
|
@@ -1264,7 +1480,7 @@ class BrowserClient {
|
|
|
1264
1480
|
async function runForEach(step, scope, walk) {
|
|
1265
1481
|
const items = await itemsOf$1(step, scope, walk);
|
|
1266
1482
|
const gate = walk.gate;
|
|
1267
|
-
if (gate?.concurrent === true) return runPooled(step, scope, walk, items, gate);
|
|
1483
|
+
if (gate?.concurrent === true && step.selector === undefined && (walk.recipe.mode === 'api' || walk.runner.fork !== undefined)) return runPooled(step, scope, walk, items, gate);
|
|
1268
1484
|
for (const item of items) {
|
|
1269
1485
|
if ((await runIteration(step, scope, walk, item)) === 'stop') return 'stop';
|
|
1270
1486
|
}
|
|
@@ -1286,17 +1502,21 @@ async function runPooled(step, scope, walk, items, gate) {
|
|
|
1286
1502
|
let stopped = false;
|
|
1287
1503
|
let failure;
|
|
1288
1504
|
const tasks = [];
|
|
1289
|
-
const
|
|
1290
|
-
gate: gate.nested()
|
|
1291
|
-
};
|
|
1505
|
+
const nested = gate.nested();
|
|
1292
1506
|
const iterate = async (item, release) => {
|
|
1507
|
+
let runner;
|
|
1293
1508
|
try {
|
|
1294
|
-
|
|
1509
|
+
runner = walk.runner.fork === undefined ? walk.runner : await walk.runner.fork();
|
|
1510
|
+
if ((await runIteration(step, scope, walk, item, {
|
|
1511
|
+
gate: nested,
|
|
1512
|
+
runner
|
|
1513
|
+
})) === 'stop') stopped = true;
|
|
1295
1514
|
} catch (error) {
|
|
1296
1515
|
failure ??= {
|
|
1297
1516
|
error
|
|
1298
1517
|
};
|
|
1299
1518
|
} finally {
|
|
1519
|
+
if (runner !== undefined && runner !== walk.runner) await disposeQuietly(runner);
|
|
1300
1520
|
release();
|
|
1301
1521
|
}
|
|
1302
1522
|
};
|
|
@@ -1604,6 +1824,7 @@ function logThrough(walk) {
|
|
|
1604
1824
|
class RunGate {
|
|
1605
1825
|
permits;
|
|
1606
1826
|
minIntervalMs;
|
|
1827
|
+
hosts;
|
|
1607
1828
|
shared;
|
|
1608
1829
|
inFlight = 0;
|
|
1609
1830
|
waiting = [];
|
|
@@ -1611,11 +1832,13 @@ class RunGate {
|
|
|
1611
1832
|
/**
|
|
1612
1833
|
* @param permits - Iterations allowed in flight; 1 is sequential.
|
|
1613
1834
|
* @param minIntervalMs - Minimum time between two request starts across the run.
|
|
1835
|
+
* @param hosts - The crawler's per-site throttle, shared with every other recipe.
|
|
1614
1836
|
* @param shared - The throttle state to share (internal: `nested` gates keep their parent's).
|
|
1615
1837
|
*/
|
|
1616
|
-
constructor(permits, minIntervalMs, shared) {
|
|
1838
|
+
constructor(permits, minIntervalMs, hosts, shared) {
|
|
1617
1839
|
this.permits = permits;
|
|
1618
1840
|
this.minIntervalMs = minIntervalMs;
|
|
1841
|
+
this.hosts = hosts;
|
|
1619
1842
|
this.shared = shared;
|
|
1620
1843
|
}
|
|
1621
1844
|
/** Whether this gate lets more than one iteration run at once. */
|
|
@@ -1652,11 +1875,258 @@ class RunGate {
|
|
|
1652
1875
|
state.lastStart = at;
|
|
1653
1876
|
await sleep(at - now);
|
|
1654
1877
|
}
|
|
1878
|
+
/**
|
|
1879
|
+
* Waits until a request to `url` may start: the recipe's interval, then its
|
|
1880
|
+
* site's turn in the crawler's per-site throttle.
|
|
1881
|
+
*
|
|
1882
|
+
* @param url - Where the request goes.
|
|
1883
|
+
* @returns The release of the site's lane: call it once the response arrived or the request failed.
|
|
1884
|
+
*/
|
|
1885
|
+
async request(url) {
|
|
1886
|
+
await this.throttle();
|
|
1887
|
+
return this.hosts === undefined ? noop$1 : this.hosts.slot(url);
|
|
1888
|
+
}
|
|
1655
1889
|
/** The gate for a body running inside an iteration that holds a permit: sequential, same throttle. */
|
|
1656
1890
|
nested() {
|
|
1657
|
-
return new RunGate(1, this.minIntervalMs, this.shared ?? this);
|
|
1891
|
+
return new RunGate(1, this.minIntervalMs, this.hosts, this.shared ?? this);
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
function noop$1() {}
|
|
1895
|
+
|
|
1896
|
+
/**
|
|
1897
|
+
* Spaces and bounds requests per site, shared by every recipe of a crawler,
|
|
1898
|
+
* so two recipes (or two parallel iterations) that hit one site add up to one
|
|
1899
|
+
* polite client rather than two. A recipe's own `limits.delayMs` still applies
|
|
1900
|
+
* on top, per recipe.
|
|
1901
|
+
*
|
|
1902
|
+
* Like a single-lane bridge with a traffic light: whoever arrives waits for
|
|
1903
|
+
* the car ahead to be far enough, and for a free lane.
|
|
1904
|
+
*/
|
|
1905
|
+
class HostThrottle {
|
|
1906
|
+
config;
|
|
1907
|
+
buckets = new Map();
|
|
1908
|
+
domains;
|
|
1909
|
+
constructor(config = {}) {
|
|
1910
|
+
this.config = config;
|
|
1911
|
+
this.domains = Object.entries(config.domains ?? {}).map(([domain, rule]) => [domain.toLowerCase().replace(/^\.+/, ''), rule]).sort((first, second) => second[0].length - first[0].length);
|
|
1912
|
+
}
|
|
1913
|
+
bucketFor(url, always = false) {
|
|
1914
|
+
const host = hostOf(url);
|
|
1915
|
+
if (host === undefined) return undefined;
|
|
1916
|
+
const match = this.domains.find(([domain]) => host === domain || host.endsWith(`.${domain}`));
|
|
1917
|
+
const key = match?.[0] ?? host;
|
|
1918
|
+
let bucket = this.buckets.get(key);
|
|
1919
|
+
if (bucket === undefined) {
|
|
1920
|
+
const rule = {
|
|
1921
|
+
...pick(this.config),
|
|
1922
|
+
...match?.[1]
|
|
1923
|
+
};
|
|
1924
|
+
if (!always && !hasLimit(rule)) return undefined;
|
|
1925
|
+
bucket = {
|
|
1926
|
+
rule,
|
|
1927
|
+
inFlight: 0,
|
|
1928
|
+
waiting: [],
|
|
1929
|
+
lastStart: Promise.resolve(-Infinity),
|
|
1930
|
+
pausedTo: 0
|
|
1931
|
+
};
|
|
1932
|
+
this.buckets.set(key, bucket);
|
|
1933
|
+
}
|
|
1934
|
+
return bucket;
|
|
1935
|
+
}
|
|
1936
|
+
/** Whether any rule can hold a request back. */
|
|
1937
|
+
get active() {
|
|
1938
|
+
return hasLimit(this.config) || this.domains.some(([, rule]) => hasLimit(rule)) || this.buckets.size > 0;
|
|
1939
|
+
}
|
|
1940
|
+
/**
|
|
1941
|
+
* Waits until a request to `url` may start, then holds one of its site's
|
|
1942
|
+
* lanes until the returned release is called.
|
|
1943
|
+
*
|
|
1944
|
+
* @param url - Where the request goes; anything but `http(s):` passes at once.
|
|
1945
|
+
* @returns The release: call it once, when the response arrived or the request failed.
|
|
1946
|
+
*/
|
|
1947
|
+
async slot(url) {
|
|
1948
|
+
const bucket = this.bucketFor(url);
|
|
1949
|
+
if (bucket === undefined) return noop;
|
|
1950
|
+
const concurrency = bucket.rule.concurrency ?? Infinity;
|
|
1951
|
+
if (bucket.inFlight >= concurrency) await new Promise(resolve => {
|
|
1952
|
+
bucket.waiting.push(resolve);
|
|
1953
|
+
});
|
|
1954
|
+
bucket.inFlight += 1;
|
|
1955
|
+
const previous = bucket.lastStart;
|
|
1956
|
+
const turn = (async () => {
|
|
1957
|
+
const after = await previous;
|
|
1958
|
+
// A pause may arrive while waiting (a Retry-After), so check it again after each sleep.
|
|
1959
|
+
for (;;) {
|
|
1960
|
+
const now = Date.now();
|
|
1961
|
+
const at = Math.max(now, after + (bucket.rule.delayMs ?? 0), bucket.pausedTo);
|
|
1962
|
+
if (at <= now) return now;
|
|
1963
|
+
await sleep(at - now);
|
|
1964
|
+
}
|
|
1965
|
+
})();
|
|
1966
|
+
bucket.lastStart = turn;
|
|
1967
|
+
await turn;
|
|
1968
|
+
let released = false;
|
|
1969
|
+
return () => {
|
|
1970
|
+
if (released) return;
|
|
1971
|
+
released = true;
|
|
1972
|
+
bucket.inFlight -= 1;
|
|
1973
|
+
bucket.waiting.shift()?.();
|
|
1974
|
+
};
|
|
1975
|
+
}
|
|
1976
|
+
/**
|
|
1977
|
+
* Holds every request to the site of `url` back until `untilMs` (a `Retry-After`).
|
|
1978
|
+
*
|
|
1979
|
+
* @param url - A URL of the site.
|
|
1980
|
+
* @param untilMs - An epoch time.
|
|
1981
|
+
*/
|
|
1982
|
+
pause(url, untilMs) {
|
|
1983
|
+
const bucket = this.bucketFor(url, true);
|
|
1984
|
+
if (bucket !== undefined) bucket.pausedTo = Math.max(bucket.pausedTo, untilMs);
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
function noop() {}
|
|
1988
|
+
function hostOf(url) {
|
|
1989
|
+
try {
|
|
1990
|
+
const parsed = new URL(url);
|
|
1991
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.hostname.toLowerCase() : undefined;
|
|
1992
|
+
} catch {
|
|
1993
|
+
return undefined;
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
function pick(config) {
|
|
1997
|
+
return {
|
|
1998
|
+
delayMs: config.delayMs,
|
|
1999
|
+
concurrency: config.concurrency
|
|
2000
|
+
};
|
|
2001
|
+
}
|
|
2002
|
+
function hasLimit(rule) {
|
|
2003
|
+
return (rule.delayMs ?? 0) > 0 || rule.concurrency !== undefined;
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
/** Statuses a server uses for "not now": timeout, too early, too many requests, and the 5xx that pass. */
|
|
2007
|
+
const RETRY_STATUSES = [408, 425, 429, 500, 502, 503, 504];
|
|
2008
|
+
/** Three tries, one second then two apart, never a wait over 30 seconds. */
|
|
2009
|
+
const DEFAULT_RETRY_RULE = {
|
|
2010
|
+
attempts: 3,
|
|
2011
|
+
backoffMs: 1000,
|
|
2012
|
+
maxDelayMs: 30_000,
|
|
2013
|
+
statuses: [...RETRY_STATUSES]
|
|
2014
|
+
};
|
|
2015
|
+
/**
|
|
2016
|
+
* Errors that say the connection failed rather than the site answered:
|
|
2017
|
+
* resets, refusals, timeouts, a DNS lookup that could not run, a proxy that
|
|
2018
|
+
* dropped the tunnel. A name that does not resolve at all (`ENOTFOUND`,
|
|
2019
|
+
* `ERR_NAME_NOT_RESOLVED`) is not here: retrying a typo only wastes time.
|
|
2020
|
+
*/
|
|
2021
|
+
const TRANSIENT_ERROR = /ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH|socket hang up|net::ERR_(?:CONNECTION_(?:RESET|REFUSED|CLOSED|ABORTED|TIMED_OUT)|TIMED_OUT|EMPTY_RESPONSE|NETWORK_CHANGED|INTERNET_DISCONNECTED|PROXY_CONNECTION_FAILED|TUNNEL_CONNECTION_FAILED|HTTP2_PROTOCOL_ERROR|NETWORK_IO_SUSPENDED)|NS_ERROR_NET_(?:RESET|INTERRUPT|TIMEOUT)|Timeout \d+ms exceeded/;
|
|
2022
|
+
/**
|
|
2023
|
+
* The retry rule a recipe runs with: its `limits.retry` over the crawler's
|
|
2024
|
+
* default over `DEFAULT_RETRY_RULE`.
|
|
2025
|
+
*
|
|
2026
|
+
* @param own - The recipe's `limits.retry`.
|
|
2027
|
+
* @param crawler - The crawler's `retry` option.
|
|
2028
|
+
* @returns The rule.
|
|
2029
|
+
*/
|
|
2030
|
+
function resolveRetryRule(own, crawler) {
|
|
2031
|
+
return {
|
|
2032
|
+
...DEFAULT_RETRY_RULE,
|
|
2033
|
+
...definedOf(crawler),
|
|
2034
|
+
...definedOf(own)
|
|
2035
|
+
};
|
|
2036
|
+
}
|
|
2037
|
+
/**
|
|
2038
|
+
* Whether an error is a connection failure worth another try.
|
|
2039
|
+
*
|
|
2040
|
+
* @param error - What the request threw.
|
|
2041
|
+
* @returns The reason, or `undefined`.
|
|
2042
|
+
*/
|
|
2043
|
+
function transientError(error) {
|
|
2044
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2045
|
+
const match = TRANSIENT_ERROR.exec(message);
|
|
2046
|
+
return match === null ? undefined : {
|
|
2047
|
+
reason: match[0]
|
|
2048
|
+
};
|
|
2049
|
+
}
|
|
2050
|
+
/**
|
|
2051
|
+
* How long to wait before attempt `attempt + 1`: the server's `Retry-After`
|
|
2052
|
+
* when it gave one, else `backoffMs` doubling per attempt with a little
|
|
2053
|
+
* jitter; `undefined` when the server asks for longer than `maxDelayMs` (it
|
|
2054
|
+
* means "come back much later", which a crawl cannot wait for).
|
|
2055
|
+
*
|
|
2056
|
+
* @param rule - The retry rule.
|
|
2057
|
+
* @param attempt - The attempt that just failed, from 1.
|
|
2058
|
+
* @param retryAfter - The `Retry-After` header: seconds, or an HTTP date.
|
|
2059
|
+
* @param now - The current time, for dates.
|
|
2060
|
+
* @returns Milliseconds, or `undefined` for no retry.
|
|
2061
|
+
*/
|
|
2062
|
+
function retryDelay(rule, attempt, retryAfter, now = Date.now()) {
|
|
2063
|
+
const asked = retryAfterMs(retryAfter, now);
|
|
2064
|
+
if (asked !== undefined) return asked > rule.maxDelayMs ? undefined : asked;
|
|
2065
|
+
const base = rule.backoffMs * 2 ** (attempt - 1);
|
|
2066
|
+
const jittered = base * (0.75 + Math.random() * 0.5);
|
|
2067
|
+
return Math.min(Math.round(jittered), rule.maxDelayMs);
|
|
2068
|
+
}
|
|
2069
|
+
/**
|
|
2070
|
+
* Sends a request through the gate (the recipe's rate, the site's lane), and
|
|
2071
|
+
* sends it again after a pause while it fails in a passing way, up to
|
|
2072
|
+
* `rule.attempts` tries in all. A `Retry-After` holds back every request to
|
|
2073
|
+
* that site, not only this one. Each retry is reported as `request:retry`.
|
|
2074
|
+
*
|
|
2075
|
+
* Like redialling a busy number: wait a moment, dial again, give up after a
|
|
2076
|
+
* few tries; and if the other end said "call back in a minute", wait that minute.
|
|
2077
|
+
*
|
|
2078
|
+
* @param url - Where the request goes.
|
|
2079
|
+
* @param attempt - How to send it and how to judge the outcome.
|
|
2080
|
+
* @param context - The recipe, gate, events and rule.
|
|
2081
|
+
* @returns What the last try gave.
|
|
2082
|
+
* @throws What the last try threw.
|
|
2083
|
+
*/
|
|
2084
|
+
async function withTransportRetry(url, attempt, context) {
|
|
2085
|
+
const {
|
|
2086
|
+
rule
|
|
2087
|
+
} = context;
|
|
2088
|
+
for (let tries = 1;; tries += 1) {
|
|
2089
|
+
const release = await context.gate.request(url);
|
|
2090
|
+
let outcome;
|
|
2091
|
+
try {
|
|
2092
|
+
outcome = {
|
|
2093
|
+
value: await attempt.run()
|
|
2094
|
+
};
|
|
2095
|
+
} catch (error) {
|
|
2096
|
+
outcome = {
|
|
2097
|
+
error
|
|
2098
|
+
};
|
|
2099
|
+
} finally {
|
|
2100
|
+
release();
|
|
2101
|
+
}
|
|
2102
|
+
const transient = tries < rule.attempts ? attempt.problem(outcome) : undefined;
|
|
2103
|
+
const delay = transient === undefined ? undefined : retryDelay(rule, tries, transient.retryAfter);
|
|
2104
|
+
if (transient === undefined || delay === undefined) {
|
|
2105
|
+
if ('error' in outcome) throw outcome.error;
|
|
2106
|
+
return outcome.value;
|
|
2107
|
+
}
|
|
2108
|
+
if (transient.retryAfter !== undefined) context.gate.hosts?.pause(url, Date.now() + delay);
|
|
2109
|
+
context.events.emit({
|
|
2110
|
+
type: 'request:retry',
|
|
2111
|
+
recipeId: context.recipeId,
|
|
2112
|
+
url,
|
|
2113
|
+
attempt: tries + 1,
|
|
2114
|
+
reason: transient.reason,
|
|
2115
|
+
delayMs: delay
|
|
2116
|
+
});
|
|
2117
|
+
await sleep(delay);
|
|
1658
2118
|
}
|
|
1659
2119
|
}
|
|
2120
|
+
function retryAfterMs(header, now) {
|
|
2121
|
+
if (header === undefined || header.trim() === '') return undefined;
|
|
2122
|
+
const seconds = Number(header.trim());
|
|
2123
|
+
if (Number.isFinite(seconds)) return Math.max(0, Math.round(seconds * 1000));
|
|
2124
|
+
const date = Date.parse(header);
|
|
2125
|
+
return Number.isNaN(date) ? undefined : Math.max(0, date - now);
|
|
2126
|
+
}
|
|
2127
|
+
function definedOf(rule) {
|
|
2128
|
+
return Object.fromEntries(Object.entries(rule ?? {}).filter(([, value]) => value !== undefined));
|
|
2129
|
+
}
|
|
1660
2130
|
|
|
1661
2131
|
/** A block unless a recipe says otherwise: forbidden, rate limited, or an AWS WAF challenge (IMDb answers 202 with it). */
|
|
1662
2132
|
const DEFAULT_BLOCK_RULE = {
|
|
@@ -2229,6 +2699,10 @@ function traceLine(event) {
|
|
|
2229
2699
|
{
|
|
2230
2700
|
return `${indent(1)}↻ new access lease (attempt ${event.attempt})`;
|
|
2231
2701
|
}
|
|
2702
|
+
case 'request:retry':
|
|
2703
|
+
{
|
|
2704
|
+
return `${indent(1)}↺ ${event.url}: ${event.reason}, try ${event.attempt} in ${event.delayMs} ms`;
|
|
2705
|
+
}
|
|
2232
2706
|
case 'captcha:detected':
|
|
2233
2707
|
{
|
|
2234
2708
|
return `${indent(1)}⚿ captcha ${event.kind} on ${event.url}`;
|
|
@@ -2437,26 +2911,36 @@ async function existingKeys(target) {
|
|
|
2437
2911
|
});
|
|
2438
2912
|
}
|
|
2439
2913
|
|
|
2440
|
-
/**
|
|
2914
|
+
/**
|
|
2915
|
+
* Drops records whose key was already seen. First record wins; keyless
|
|
2916
|
+
* records always pass. Recipes running in parallel each get their own view:
|
|
2917
|
+
* under `recipe` scope they never see each other's keys, under `run` scope
|
|
2918
|
+
* they share them (and whichever emits a key first keeps it).
|
|
2919
|
+
*/
|
|
2441
2920
|
class DedupePolicy {
|
|
2442
2921
|
scope;
|
|
2443
|
-
|
|
2922
|
+
shared = new Set();
|
|
2444
2923
|
constructor(scope = 'run') {
|
|
2445
2924
|
this.scope = scope;
|
|
2446
2925
|
}
|
|
2447
|
-
/** Called when an input recipe starts; forgets keys under `recipe` scope. */
|
|
2448
|
-
startRecipe() {
|
|
2449
|
-
if (this.scope === 'recipe') this.seen = new Set();
|
|
2450
|
-
}
|
|
2451
2926
|
/**
|
|
2452
|
-
*
|
|
2453
|
-
*
|
|
2927
|
+
* The de-duplication one input recipe run uses.
|
|
2928
|
+
*
|
|
2929
|
+
* @returns Its view: keys shared with the run, its own, or none checked.
|
|
2454
2930
|
*/
|
|
2455
|
-
|
|
2456
|
-
if (this.scope === 'off'
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2931
|
+
forRecipe() {
|
|
2932
|
+
if (this.scope === 'off') return {
|
|
2933
|
+
isDuplicate: () => false
|
|
2934
|
+
};
|
|
2935
|
+
const seen = this.scope === 'run' ? this.shared : new Set();
|
|
2936
|
+
return {
|
|
2937
|
+
isDuplicate: record => {
|
|
2938
|
+
if (record.key === null) return false;
|
|
2939
|
+
if (seen.has(record.key)) return true;
|
|
2940
|
+
seen.add(record.key);
|
|
2941
|
+
return false;
|
|
2942
|
+
}
|
|
2943
|
+
};
|
|
2460
2944
|
}
|
|
2461
2945
|
}
|
|
2462
2946
|
|
|
@@ -2507,10 +2991,13 @@ const DOCUMENT = /^\s*(?:<!doctype|<html)/i;
|
|
|
2507
2991
|
*
|
|
2508
2992
|
* @param html - The markup (a whole document or a fragment).
|
|
2509
2993
|
* @param selector - A CSS selector.
|
|
2994
|
+
* @param xml - Read the markup as XML: names keep their case, `take: "html"` gives XML.
|
|
2510
2995
|
* @returns Every match, in document order.
|
|
2511
2996
|
*/
|
|
2512
|
-
function selectHtml(html, selector) {
|
|
2513
|
-
const api =
|
|
2997
|
+
function selectHtml(html, selector, xml = false) {
|
|
2998
|
+
const api = xml ? load(html, {
|
|
2999
|
+
xml: true
|
|
3000
|
+
}) : DOCUMENT.test(html) ? load(html) : load(html, undefined, false);
|
|
2514
3001
|
return api(selector).map((_, element) => ({
|
|
2515
3002
|
api,
|
|
2516
3003
|
element: api(element)
|
|
@@ -3557,8 +4044,8 @@ function filledGrid(sheet) {
|
|
|
3557
4044
|
/** `B10:B13` as 0-based bounds; `undefined` for anything else. */
|
|
3558
4045
|
function rangeOf(reference) {
|
|
3559
4046
|
const [from, to = from] = reference.split(':', 2);
|
|
3560
|
-
const start = cellOf(from);
|
|
3561
|
-
const end = cellOf(to);
|
|
4047
|
+
const start = cellOf$1(from);
|
|
4048
|
+
const end = cellOf$1(to);
|
|
3562
4049
|
if (start === undefined || end === undefined) return undefined;
|
|
3563
4050
|
return {
|
|
3564
4051
|
top: Math.min(start.row, end.row),
|
|
@@ -3567,7 +4054,7 @@ function rangeOf(reference) {
|
|
|
3567
4054
|
right: Math.max(start.column, end.column)
|
|
3568
4055
|
};
|
|
3569
4056
|
}
|
|
3570
|
-
function cellOf(reference) {
|
|
4057
|
+
function cellOf$1(reference) {
|
|
3571
4058
|
const match = /^\$?([A-Z]+)\$?(\d+)$/i.exec(reference.trim());
|
|
3572
4059
|
if (match === null) return undefined;
|
|
3573
4060
|
const letters = match[1].toUpperCase();
|
|
@@ -3700,13 +4187,194 @@ function shapeTables(document, slides, query) {
|
|
|
3700
4187
|
}));
|
|
3701
4188
|
}
|
|
3702
4189
|
|
|
4190
|
+
/**
|
|
4191
|
+
* Whether a value is a read XML document.
|
|
4192
|
+
*
|
|
4193
|
+
* @param value - Anything bound in a scope.
|
|
4194
|
+
* @returns `true` for an `XmlDocument`.
|
|
4195
|
+
*/
|
|
4196
|
+
function isXmlDocument(value) {
|
|
4197
|
+
return typeof value === 'object' && value !== null && value.kind === 'xml' && typeof value.xml === 'string';
|
|
4198
|
+
}
|
|
4199
|
+
|
|
4200
|
+
/** The documents already parsed, by the text they came from, so a recipe's extracts parse a body once. */
|
|
4201
|
+
const parsed = new Map();
|
|
4202
|
+
const PARSED_LIMIT = 16;
|
|
4203
|
+
/**
|
|
4204
|
+
* Parses XML strictly but safely: a malformed document is an error naming
|
|
4205
|
+
* the URL and the line; entities a DOCTYPE declares are never expanded (no
|
|
4206
|
+
* "billion laughs") and nothing external is ever fetched (no XXE), they stay
|
|
4207
|
+
* as they are.
|
|
4208
|
+
*
|
|
4209
|
+
* @param xml - The document text.
|
|
4210
|
+
* @param where - The URL or id, for messages.
|
|
4211
|
+
* @returns The document.
|
|
4212
|
+
* @throws Error when the text is not well-formed XML.
|
|
4213
|
+
*/
|
|
4214
|
+
function parseXml(xml, where) {
|
|
4215
|
+
const cached = parsed.get(xml);
|
|
4216
|
+
if (cached !== undefined) return cached;
|
|
4217
|
+
let document;
|
|
4218
|
+
try {
|
|
4219
|
+
document = new DOMParser({
|
|
4220
|
+
onError: failOnFatal
|
|
4221
|
+
}).parseFromString(xml, 'text/xml');
|
|
4222
|
+
} catch (error) {
|
|
4223
|
+
throw new Error(`${where}: not well-formed XML (${firstLine(error)})`, {
|
|
4224
|
+
cause: error
|
|
4225
|
+
});
|
|
4226
|
+
}
|
|
4227
|
+
if (document.documentElement === null) throw new Error(`${where}: not an XML document (no root element)`);
|
|
4228
|
+
remember(xml, document);
|
|
4229
|
+
return document;
|
|
4230
|
+
}
|
|
4231
|
+
/**
|
|
4232
|
+
* HTML as an XML document, for XPath on fetched pages: parsed the way a
|
|
4233
|
+
* browser parses it (a forgiving HTML5 parser that inserts `<tbody>` and
|
|
4234
|
+
* closes what the page left open), without namespaces, so `//table/tbody/tr`
|
|
4235
|
+
* finds what it finds on the live page.
|
|
4236
|
+
*
|
|
4237
|
+
* @param html - A page or a fragment.
|
|
4238
|
+
* @returns The document.
|
|
4239
|
+
*/
|
|
4240
|
+
function htmlAsXml(html) {
|
|
4241
|
+
const cached = parsed.get(html);
|
|
4242
|
+
if (cached !== undefined) return cached;
|
|
4243
|
+
const whole = /^\s*(?:<!doctype|<html)/i.test(html);
|
|
4244
|
+
// A fragment (a row taken with `take: "html"`) keeps its cells, and gets one root to be a document.
|
|
4245
|
+
const xml = whole ? load(html).xml() : `<fragment>${load(html, undefined, false).xml()}</fragment>`;
|
|
4246
|
+
const document = withoutNamespaces(new DOMParser({
|
|
4247
|
+
onError: ignore
|
|
4248
|
+
}).parseFromString(xml, 'text/xml'));
|
|
4249
|
+
remember(html, document);
|
|
4250
|
+
return document;
|
|
4251
|
+
}
|
|
4252
|
+
/**
|
|
4253
|
+
* A copy of a document with every namespace dropped: elements and attributes
|
|
4254
|
+
* keep their local names, `xmlns` declarations go. What `ignoreNamespaces`
|
|
4255
|
+
* queries run on: `//entry/title` instead of `//atom:entry/atom:title`.
|
|
4256
|
+
*
|
|
4257
|
+
* @param document - A parsed document.
|
|
4258
|
+
* @returns The copy.
|
|
4259
|
+
*/
|
|
4260
|
+
function withoutNamespaces(document) {
|
|
4261
|
+
const copy = new DOMImplementation().createDocument(null, '', null);
|
|
4262
|
+
const clone = node => {
|
|
4263
|
+
if (node.nodeType === node.ELEMENT_NODE) {
|
|
4264
|
+
const element = node;
|
|
4265
|
+
const target = copy.createElement(element.localName);
|
|
4266
|
+
for (const attribute of element.attributes) {
|
|
4267
|
+
if (attribute.name === 'xmlns' || attribute.prefix === 'xmlns') continue;
|
|
4268
|
+
target.setAttribute(attribute.localName, attribute.value);
|
|
4269
|
+
}
|
|
4270
|
+
for (const child of element.childNodes) {
|
|
4271
|
+
const cloned = clone(child);
|
|
4272
|
+
if (cloned !== undefined) target.insertBefore(cloned, null);
|
|
4273
|
+
}
|
|
4274
|
+
return target;
|
|
4275
|
+
}
|
|
4276
|
+
if (node.nodeType === node.TEXT_NODE || node.nodeType === node.CDATA_SECTION_NODE) return copy.createTextNode(node.nodeValue ?? '');
|
|
4277
|
+
return undefined;
|
|
4278
|
+
};
|
|
4279
|
+
const root = document.documentElement === null ? undefined : clone(document.documentElement);
|
|
4280
|
+
if (root !== undefined) copy.insertBefore(root, null);
|
|
4281
|
+
return copy;
|
|
4282
|
+
}
|
|
4283
|
+
/**
|
|
4284
|
+
* A node as markup: the element with its children (`outer`), or its children only.
|
|
4285
|
+
*
|
|
4286
|
+
* @param node - A node.
|
|
4287
|
+
* @param outer - Whether to include the node itself.
|
|
4288
|
+
* @returns The markup.
|
|
4289
|
+
*/
|
|
4290
|
+
function serializeXml(node, outer) {
|
|
4291
|
+
const serializer = new XMLSerializer();
|
|
4292
|
+
if (outer) return serializer.serializeToString(node);
|
|
4293
|
+
return Array.from(node.childNodes, child => serializer.serializeToString(child)).join('');
|
|
4294
|
+
}
|
|
4295
|
+
/**
|
|
4296
|
+
* The text of an XML body, gunzipped first when it is gzip (a `sitemap.xml.gz`).
|
|
4297
|
+
*
|
|
4298
|
+
* @param bytes - The body.
|
|
4299
|
+
* @returns The bytes to decode.
|
|
4300
|
+
*/
|
|
4301
|
+
function gunzipIfNeeded(bytes) {
|
|
4302
|
+
return bytes.length > 2 && bytes[0] === 0x1F && bytes[1] === 0x8B ? gunzipSync(bytes) : bytes;
|
|
4303
|
+
}
|
|
4304
|
+
function remember(text, document) {
|
|
4305
|
+
if (parsed.size >= PARSED_LIMIT) parsed.delete(parsed.keys().next().value);
|
|
4306
|
+
parsed.set(text, document);
|
|
4307
|
+
}
|
|
4308
|
+
function failOnFatal(level, message) {
|
|
4309
|
+
if (level === 'fatalError') throw new Error(message);
|
|
4310
|
+
}
|
|
4311
|
+
function ignore() {}
|
|
4312
|
+
function firstLine(error) {
|
|
4313
|
+
return (error instanceof Error ? error.message : String(error)).split('\n', 1)[0];
|
|
4314
|
+
}
|
|
4315
|
+
|
|
4316
|
+
/**
|
|
4317
|
+
* Runs an XPath 1.0 query on a parsed document. Prefixes the root element
|
|
4318
|
+
* declares are known without being listed; a default namespace has no
|
|
4319
|
+
* prefix, so name one in `namespaces` or use `ignoreNamespaces`.
|
|
4320
|
+
*
|
|
4321
|
+
* @param document - The document.
|
|
4322
|
+
* @param expression - The query.
|
|
4323
|
+
* @param options - Namespaces.
|
|
4324
|
+
* @returns Every node selected, or the one value a function returned.
|
|
4325
|
+
* @throws Error naming the query when it does not parse or uses an unknown prefix.
|
|
4326
|
+
*/
|
|
4327
|
+
function selectXpath(document, expression, options = {}) {
|
|
4328
|
+
const target = options.ignoreNamespaces === true ? withoutNamespaces(document) : document;
|
|
4329
|
+
const namespaces = {
|
|
4330
|
+
...declaredPrefixes(target),
|
|
4331
|
+
...options.namespaces
|
|
4332
|
+
};
|
|
4333
|
+
let result;
|
|
4334
|
+
try {
|
|
4335
|
+
result = useNamespaces(namespaces)(expression, target);
|
|
4336
|
+
} catch (error) {
|
|
4337
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4338
|
+
const hint = /prefix|namespace|QName/i.test(message) ? ' (declare it in "namespaces", or set "ignoreNamespaces": true)' : '';
|
|
4339
|
+
throw new Error(`xpath ${expression}: ${message}${hint}`, {
|
|
4340
|
+
cause: error
|
|
4341
|
+
});
|
|
4342
|
+
}
|
|
4343
|
+
if (Array.isArray(result)) return result;
|
|
4344
|
+
if (result === null) return [];
|
|
4345
|
+
return [result];
|
|
4346
|
+
}
|
|
4347
|
+
/**
|
|
4348
|
+
* The value of an XPath result.
|
|
4349
|
+
*
|
|
4350
|
+
* @param value - A node or a function result.
|
|
4351
|
+
* @param take - `text` (whitespace collapsed), `html` (the node's inner markup), `json` (its outer markup), `value` (its text as is), `attr:<name>`.
|
|
4352
|
+
* @returns The value; `undefined` for a missing attribute.
|
|
4353
|
+
*/
|
|
4354
|
+
function takeFromXml(value, take) {
|
|
4355
|
+
if (typeof value !== 'object') return take === 'text' ? collapse(String(value)) : value;
|
|
4356
|
+
if (take === 'text') return collapse(value.textContent ?? '');
|
|
4357
|
+
if (take === 'value') return value.textContent ?? '';
|
|
4358
|
+
if (take === 'html') return serializeXml(value, false);
|
|
4359
|
+
if (take === 'json') return serializeXml(value, true);
|
|
4360
|
+
if (value.nodeType !== value.ELEMENT_NODE) return undefined;
|
|
4361
|
+
const element = value;
|
|
4362
|
+
const name = take.slice('attr:'.length);
|
|
4363
|
+
return element.hasAttribute(name) ? element.getAttribute(name) : undefined;
|
|
4364
|
+
}
|
|
4365
|
+
function declaredPrefixes(document) {
|
|
4366
|
+
const root = document.documentElement;
|
|
4367
|
+
if (root === null) return {};
|
|
4368
|
+
return Object.fromEntries([...root.attributes].filter(attribute => attribute.prefix === 'xmlns').map(attribute => [attribute.localName, attribute.value]));
|
|
4369
|
+
}
|
|
4370
|
+
|
|
3703
4371
|
/**
|
|
3704
4372
|
* Runs an `extract` step against a static document: the value bound under
|
|
3705
|
-
* `from`, else the scope's current document. `css` reads HTML,
|
|
3706
|
-
* reads
|
|
3707
|
-
*
|
|
3708
|
-
*
|
|
3709
|
-
*
|
|
4373
|
+
* `from`, else the scope's current document. `css` reads HTML (or XML),
|
|
4374
|
+
* `xpath` reads XML (a feed, a sitemap) or HTML parsed as a browser parses
|
|
4375
|
+
* it, `jsonpath` reads JSON (or a read PDF, workbook or deck as data), `table`
|
|
4376
|
+
* reads the tables of a PDF, a workbook (a spreadsheet, a CSV), a deck (a
|
|
4377
|
+
* presentation) or HTML (its `<table>`s), `regex` reads any document as text.
|
|
3710
4378
|
*
|
|
3711
4379
|
* A `jsonpath` extract whose `from` is text parses that text as JSON, and a
|
|
3712
4380
|
* list of texts (every `<script type="application/ld+json">` of a page) becomes
|
|
@@ -3725,6 +4393,7 @@ function extractFromDocument(step, scope) {
|
|
|
3725
4393
|
switch (step.kind) {
|
|
3726
4394
|
case 'jsonpath':
|
|
3727
4395
|
{
|
|
4396
|
+
if (document.kind === 'xml') throw new Error('jsonpath needs a JSON document; the current document is xml (read it with kind "xpath")');
|
|
3728
4397
|
if (document.kind === 'html' || document.kind === 'text') throw new Error(`jsonpath needs a JSON document; the current document is ${document.kind}`);
|
|
3729
4398
|
values = selectJson(document.kind === 'json' ? document.data : document, selector).map(node => takeFromJson(node, take));
|
|
3730
4399
|
break;
|
|
@@ -3736,10 +4405,22 @@ function extractFromDocument(step, scope) {
|
|
|
3736
4405
|
}
|
|
3737
4406
|
case 'css':
|
|
3738
4407
|
{
|
|
4408
|
+
if (document.kind === 'xml') {
|
|
4409
|
+
values = selectHtml(document.xml, selector, true).map(match => takeFromHtml(match, take));
|
|
4410
|
+
break;
|
|
4411
|
+
}
|
|
3739
4412
|
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")' : ''}`);
|
|
3740
4413
|
values = selectHtml(document.html, selector).map(match => takeFromHtml(match, take));
|
|
3741
4414
|
break;
|
|
3742
4415
|
}
|
|
4416
|
+
case 'xpath':
|
|
4417
|
+
{
|
|
4418
|
+
values = selectXpath(xpathDocument(document), selector, {
|
|
4419
|
+
namespaces: step.namespaces,
|
|
4420
|
+
ignoreNamespaces: step.ignoreNamespaces
|
|
4421
|
+
}).map(node => takeFromXml(node, take));
|
|
4422
|
+
break;
|
|
4423
|
+
}
|
|
3743
4424
|
case 'regex':
|
|
3744
4425
|
{
|
|
3745
4426
|
values = selectRegex(textOf$1(document), selector);
|
|
@@ -3747,7 +4428,7 @@ function extractFromDocument(step, scope) {
|
|
|
3747
4428
|
}
|
|
3748
4429
|
default:
|
|
3749
4430
|
{
|
|
3750
|
-
throw new Error(
|
|
4431
|
+
throw new Error(`unknown extract kind ${String(step.kind)}`);
|
|
3751
4432
|
}
|
|
3752
4433
|
}
|
|
3753
4434
|
if (step.many === true) {
|
|
@@ -3853,9 +4534,32 @@ function patternOf(source, where) {
|
|
|
3853
4534
|
});
|
|
3854
4535
|
}
|
|
3855
4536
|
}
|
|
4537
|
+
/** What an xpath extract queries: XML as it is, HTML parsed the way a browser would. */
|
|
4538
|
+
function xpathDocument(document) {
|
|
4539
|
+
if (document.kind === 'xml') return parseXml(document.xml, 'the document');
|
|
4540
|
+
if (document.kind === 'html') return htmlAsXml(document.html);
|
|
4541
|
+
if (document.kind === 'text') return parseXml(document.text, 'the document');
|
|
4542
|
+
throw new Error(`xpath reads XML or HTML; the current document is ${document.kind} (read it with kind ${document.kind === 'json' ? '"jsonpath"' : '"table", "regex" or "jsonpath"'})`);
|
|
4543
|
+
}
|
|
4544
|
+
/** Markup bound to an id: XML when it is well-formed (a feed entry taken with `take: "json"`), else HTML. */
|
|
4545
|
+
function markupDocument(markup) {
|
|
4546
|
+
try {
|
|
4547
|
+
parseXml(markup, 'markup');
|
|
4548
|
+
return {
|
|
4549
|
+
kind: 'xml',
|
|
4550
|
+
xml: markup
|
|
4551
|
+
};
|
|
4552
|
+
} catch {
|
|
4553
|
+
return {
|
|
4554
|
+
kind: 'html',
|
|
4555
|
+
html: markup
|
|
4556
|
+
};
|
|
4557
|
+
}
|
|
4558
|
+
}
|
|
3856
4559
|
/** 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). */
|
|
3857
4560
|
function textOf$1(document) {
|
|
3858
4561
|
if (document.kind === 'html') return document.html;
|
|
4562
|
+
if (document.kind === 'xml') return document.xml;
|
|
3859
4563
|
if (document.kind === 'text') return document.text;
|
|
3860
4564
|
if (document.kind === 'pdf') return pdfText(document);
|
|
3861
4565
|
if (document.kind === 'workbook') return workbookText(document);
|
|
@@ -3871,7 +4575,11 @@ function documentFor(step, scope) {
|
|
|
3871
4575
|
}
|
|
3872
4576
|
const source = scope.get(step.from);
|
|
3873
4577
|
if (source === undefined) throw new Error(`"${step.from}" is not bound`);
|
|
3874
|
-
if (isPdfDocument(source) || isWorkbookDocument(source) || isDeckDocument(source)) return source;
|
|
4578
|
+
if (isPdfDocument(source) || isWorkbookDocument(source) || isDeckDocument(source) || isXmlDocument(source)) return source;
|
|
4579
|
+
if (step.kind === 'xpath') {
|
|
4580
|
+
if (typeof source !== 'string') throw new Error(`"${step.from}" is not markup; xpath reads XML or HTML text`);
|
|
4581
|
+
return markupDocument(source);
|
|
4582
|
+
}
|
|
3875
4583
|
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"`);
|
|
3876
4584
|
if (step.kind === 'regex') {
|
|
3877
4585
|
if (typeof source === 'string') return {
|
|
@@ -3960,6 +4668,12 @@ async function readYaml(text, source, scalars = 'typed') {
|
|
|
3960
4668
|
};
|
|
3961
4669
|
}
|
|
3962
4670
|
|
|
4671
|
+
/**
|
|
4672
|
+
* `<` as the JSON escape `\u003c`, so a front matter value holding `</script>`
|
|
4673
|
+
* cannot close its element. Built from char codes: a formatter would turn a
|
|
4674
|
+
* literal escape back into `<`.
|
|
4675
|
+
*/
|
|
4676
|
+
const ESCAPED_LESS_THAN = String.fromCodePoint(92, 117, 48, 48, 51, 99);
|
|
3963
4677
|
/** A leading `---` block of YAML. */
|
|
3964
4678
|
const FRONT_MATTER = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
|
|
3965
4679
|
/**
|
|
@@ -3993,14 +4707,22 @@ async function readMarkdown(text, source) {
|
|
|
3993
4707
|
async: false
|
|
3994
4708
|
});
|
|
3995
4709
|
const sections = sectioned(rendered);
|
|
3996
|
-
const head = front === undefined ? '' : `<script type="application/json" data-front-matter>${JSON.stringify(front.data ?? null).replaceAll('<',
|
|
4710
|
+
const head = front === undefined ? '' : `<script type="application/json" data-front-matter>${JSON.stringify(front.data ?? null).replaceAll('<', () => ESCAPED_LESS_THAN)}</script>`;
|
|
3997
4711
|
return {
|
|
3998
4712
|
html: `<!doctype html><html><head>${head}</head><body>${sections}</body></html>`,
|
|
3999
4713
|
frontMatter: front?.data,
|
|
4000
4714
|
warnings: front?.warnings ?? []
|
|
4001
4715
|
};
|
|
4002
4716
|
}
|
|
4003
|
-
/**
|
|
4717
|
+
/**
|
|
4718
|
+
* Wraps each heading and what follows it, up to the next heading of the same
|
|
4719
|
+
* or a higher level, in `<section data-heading="…" data-level="…">`, sections
|
|
4720
|
+
* nesting; headings get slug ids. Rendered Markdown and Word documents both go
|
|
4721
|
+
* through it, so one selector finds "the table under *Prezzi*" in either.
|
|
4722
|
+
*
|
|
4723
|
+
* @param html - Top-level HTML: headings among paragraphs, lists, tables.
|
|
4724
|
+
* @returns The same content, sectioned.
|
|
4725
|
+
*/
|
|
4004
4726
|
function sectioned(html) {
|
|
4005
4727
|
const $ = load(html, null, false);
|
|
4006
4728
|
const open = [];
|
|
@@ -4021,7 +4743,7 @@ function sectioned(html) {
|
|
|
4021
4743
|
const heading = $(node);
|
|
4022
4744
|
const text = heading.text().replaceAll(/\s+/g, ' ').trim();
|
|
4023
4745
|
heading.attr('id', uniqueSlug(text, used));
|
|
4024
|
-
out += `<section data-heading="${escapeAttribute(text)}" data-level="${depth}">${$.html(node)}`;
|
|
4746
|
+
out += `<section data-heading="${escapeAttribute$1(text)}" data-level="${depth}">${$.html(node)}`;
|
|
4025
4747
|
open.push(depth);
|
|
4026
4748
|
}
|
|
4027
4749
|
return out + '</section>'.repeat(open.length);
|
|
@@ -4033,10 +4755,165 @@ function uniqueSlug(text, used) {
|
|
|
4033
4755
|
used.set(slug, count + 1);
|
|
4034
4756
|
return count === 0 ? slug : `${slug}-${count}`;
|
|
4035
4757
|
}
|
|
4036
|
-
function escapeAttribute(text) {
|
|
4758
|
+
function escapeAttribute$1(text) {
|
|
4037
4759
|
return text.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<');
|
|
4038
4760
|
}
|
|
4039
4761
|
|
|
4762
|
+
/**
|
|
4763
|
+
* Reads a `.docx` Word document into HTML, through `@opencraw/office-reader`,
|
|
4764
|
+
* so every selector that reads a page reads a Word document too:
|
|
4765
|
+
*
|
|
4766
|
+
* - headings become `<h1>`…`<h6>` inside `<section data-heading="…">`, as in
|
|
4767
|
+
* rendered Markdown;
|
|
4768
|
+
* - list items become nested `<ul>` / `<ol>`;
|
|
4769
|
+
* - tables become `<table>`s with their merged cells as `colspan` / `rowspan`,
|
|
4770
|
+
* so a `table` extract reads them like any HTML table;
|
|
4771
|
+
* - a paragraph keeps its style as `data-style` and its links as `<a href>`;
|
|
4772
|
+
* - headers, footers and notes follow the body, in `<header>`, `<footer>`
|
|
4773
|
+
* and `<aside data-part="notes">`; the title goes to `<title>`.
|
|
4774
|
+
*
|
|
4775
|
+
* The reader is imported on first use.
|
|
4776
|
+
*
|
|
4777
|
+
* @param bytes - The file.
|
|
4778
|
+
* @param source - Where it came from, for messages.
|
|
4779
|
+
* @returns The HTML document.
|
|
4780
|
+
* @throws Error naming the source, and saying what to do, for a file that is
|
|
4781
|
+
* not a readable Word document (a legacy `.doc`, a password-protected file, an `.odt`…).
|
|
4782
|
+
*/
|
|
4783
|
+
async function readDocxHtml(bytes, source) {
|
|
4784
|
+
const {
|
|
4785
|
+
readDocx,
|
|
4786
|
+
OfficeReadError
|
|
4787
|
+
} = await import('@opencraw/office-reader/docx');
|
|
4788
|
+
let document;
|
|
4789
|
+
try {
|
|
4790
|
+
document = await readDocx(bytes);
|
|
4791
|
+
} catch (error) {
|
|
4792
|
+
if (error instanceof OfficeReadError) throw new Error(`${source}: ${error.message}`, {
|
|
4793
|
+
cause: error
|
|
4794
|
+
});
|
|
4795
|
+
throw error;
|
|
4796
|
+
}
|
|
4797
|
+
const head = document.title === undefined ? '' : `<title>${escapeText(document.title)}</title>`;
|
|
4798
|
+
const headers = document.headers.map(blocks => `<header data-part="header">${blocksHtml(blocks)}</header>`).join('');
|
|
4799
|
+
const footers = document.footers.map(blocks => `<footer data-part="footer">${blocksHtml(blocks)}</footer>`).join('');
|
|
4800
|
+
const notes = document.notes.length === 0 ? '' : `<aside data-part="notes"><ol>${document.notes.map(note => `<li id="${note.kind}-${escapeAttribute(note.id)}" data-kind="${note.kind}">${textHtml(note.text)}</li>`).join('')}</ol></aside>`;
|
|
4801
|
+
return `<!doctype html><html><head>${head}</head><body>${headers}${sectioned(blocksHtml(document.body))}${footers}${notes}</body></html>`;
|
|
4802
|
+
}
|
|
4803
|
+
/** Blocks as HTML: headings, paragraphs, nested lists, tables. */
|
|
4804
|
+
function blocksHtml(blocks) {
|
|
4805
|
+
let html = '';
|
|
4806
|
+
const lists = [];
|
|
4807
|
+
const closeLists = depth => {
|
|
4808
|
+
while (lists.length > depth) {
|
|
4809
|
+
const list = lists.pop();
|
|
4810
|
+
html += `${list?.itemOpen === true ? '</li>' : ''}</${list?.tag ?? 'ul'}>`;
|
|
4811
|
+
}
|
|
4812
|
+
};
|
|
4813
|
+
for (const block of blocks) {
|
|
4814
|
+
if (block.kind === 'table') {
|
|
4815
|
+
closeLists(0);
|
|
4816
|
+
html += tableHtml(block);
|
|
4817
|
+
continue;
|
|
4818
|
+
}
|
|
4819
|
+
if (block.list === undefined) {
|
|
4820
|
+
closeLists(0);
|
|
4821
|
+
html += paragraphHtml(block);
|
|
4822
|
+
continue;
|
|
4823
|
+
}
|
|
4824
|
+
const tag = block.list.ordered ? 'ol' : 'ul';
|
|
4825
|
+
const depth = block.list.level + 1;
|
|
4826
|
+
closeLists(depth);
|
|
4827
|
+
if (lists.length === depth && lists.at(-1)?.tag !== tag) closeLists(depth - 1);
|
|
4828
|
+
while (lists.length < depth) {
|
|
4829
|
+
html += `<${tag}>`;
|
|
4830
|
+
lists.push({
|
|
4831
|
+
tag,
|
|
4832
|
+
itemOpen: false
|
|
4833
|
+
});
|
|
4834
|
+
}
|
|
4835
|
+
const list = lists[depth - 1];
|
|
4836
|
+
html += `${list.itemOpen ? '</li>' : ''}<li${styleAttribute(block)}>${inlineHtml(block)}`;
|
|
4837
|
+
list.itemOpen = true;
|
|
4838
|
+
}
|
|
4839
|
+
closeLists(0);
|
|
4840
|
+
return html;
|
|
4841
|
+
}
|
|
4842
|
+
function paragraphHtml(paragraph) {
|
|
4843
|
+
if (paragraph.heading !== undefined) {
|
|
4844
|
+
const level = Math.min(paragraph.heading, 6);
|
|
4845
|
+
return `<h${level}${styleAttribute(paragraph)}>${inlineHtml(paragraph)}</h${level}>`;
|
|
4846
|
+
}
|
|
4847
|
+
return `<p${styleAttribute(paragraph)}>${inlineHtml(paragraph)}</p>`;
|
|
4848
|
+
}
|
|
4849
|
+
/** A paragraph's text with its links as anchors, in the order they appear. */
|
|
4850
|
+
function inlineHtml(paragraph) {
|
|
4851
|
+
let html = '';
|
|
4852
|
+
let rest = paragraph.text;
|
|
4853
|
+
const links = paragraph.links ?? [];
|
|
4854
|
+
for (const link of links) {
|
|
4855
|
+
const at = rest.indexOf(link.text);
|
|
4856
|
+
if (at === -1) continue;
|
|
4857
|
+
html += `${textHtml(rest.slice(0, at))}<a href="${escapeAttribute(link.href)}">${textHtml(link.text)}</a>`;
|
|
4858
|
+
rest = rest.slice(at + link.text.length);
|
|
4859
|
+
}
|
|
4860
|
+
return html + textHtml(rest);
|
|
4861
|
+
}
|
|
4862
|
+
/** A grid as a table: the top-left cell of a merged range spans it, the cells it covers are left out. */
|
|
4863
|
+
function tableHtml(table) {
|
|
4864
|
+
const spans = new Map();
|
|
4865
|
+
const covered = new Set();
|
|
4866
|
+
for (const range of table.merges) {
|
|
4867
|
+
const [from, to] = range.split(':').map(reference => cellOf(reference));
|
|
4868
|
+
if (from === undefined || to === undefined) continue;
|
|
4869
|
+
spans.set(`${from.row},${from.column}`, {
|
|
4870
|
+
rows: to.row - from.row + 1,
|
|
4871
|
+
columns: to.column - from.column + 1
|
|
4872
|
+
});
|
|
4873
|
+
for (let row = from.row; row <= to.row; row += 1) {
|
|
4874
|
+
for (let column = from.column; column <= to.column; column += 1) {
|
|
4875
|
+
if (row !== from.row || column !== from.column) covered.add(`${row},${column}`);
|
|
4876
|
+
}
|
|
4877
|
+
}
|
|
4878
|
+
}
|
|
4879
|
+
const rows = table.rows.map((cells, row) => {
|
|
4880
|
+
const html = cells.map((text, column) => {
|
|
4881
|
+
const key = `${row},${column}`;
|
|
4882
|
+
if (covered.has(key)) return '';
|
|
4883
|
+
const span = spans.get(key);
|
|
4884
|
+
const attributes = span === undefined ? '' : `${span.rows > 1 ? ` rowspan="${span.rows}"` : ''}${span.columns > 1 ? ` colspan="${span.columns}"` : ''}`;
|
|
4885
|
+
return `<td${attributes}>${textHtml(text)}</td>`;
|
|
4886
|
+
}).join('');
|
|
4887
|
+
return `<tr>${html}</tr>`;
|
|
4888
|
+
}).join('');
|
|
4889
|
+
return `<table data-name="${escapeAttribute(table.name)}">${rows}</table>`;
|
|
4890
|
+
}
|
|
4891
|
+
/** An A1 reference as 0-based row and column. */
|
|
4892
|
+
function cellOf(reference) {
|
|
4893
|
+
const match = /^([A-Z]+)(\d+)$/.exec(reference);
|
|
4894
|
+
if (match === null) return undefined;
|
|
4895
|
+
let column = 0;
|
|
4896
|
+
const letters = match[1];
|
|
4897
|
+
for (const letter of letters) column = column * 26 + (letter.codePointAt(0) ?? 64) - 64;
|
|
4898
|
+
return {
|
|
4899
|
+
row: Number(match[2]) - 1,
|
|
4900
|
+
column: column - 1
|
|
4901
|
+
};
|
|
4902
|
+
}
|
|
4903
|
+
function styleAttribute(paragraph) {
|
|
4904
|
+
return paragraph.style === undefined ? '' : ` data-style="${escapeAttribute(paragraph.style)}"`;
|
|
4905
|
+
}
|
|
4906
|
+
/** Text as HTML: escaped, line breaks as `<br>`. */
|
|
4907
|
+
function textHtml(text) {
|
|
4908
|
+
return escapeText(text).replaceAll('\n', '<br>');
|
|
4909
|
+
}
|
|
4910
|
+
function escapeText(text) {
|
|
4911
|
+
return text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
|
4912
|
+
}
|
|
4913
|
+
function escapeAttribute(text) {
|
|
4914
|
+
return escapeText(text).replaceAll('"', '"');
|
|
4915
|
+
}
|
|
4916
|
+
|
|
4040
4917
|
/** A response with a 4xx or 5xx status. */
|
|
4041
4918
|
class HttpError extends Error {
|
|
4042
4919
|
status;
|
|
@@ -4196,7 +5073,7 @@ class HttpClient {
|
|
|
4196
5073
|
}
|
|
4197
5074
|
async function readBody(response, httpRequest) {
|
|
4198
5075
|
const contentType = response.headers()['content-type'] ?? '';
|
|
4199
|
-
const format = httpRequest.as ?? formatFromContentType(contentType);
|
|
5076
|
+
const format = httpRequest.as ?? formatFromContentType(contentType, response.url());
|
|
4200
5077
|
return parseBody(format, await response.body(), response.url(), {
|
|
4201
5078
|
...httpRequest,
|
|
4202
5079
|
charset: charsetOf(contentType)
|
|
@@ -4214,7 +5091,7 @@ async function readLocalFile(httpRequest) {
|
|
|
4214
5091
|
body,
|
|
4215
5092
|
warnings,
|
|
4216
5093
|
format
|
|
4217
|
-
} = await parseBody(httpRequest.as ?? formatFromExtension(
|
|
5094
|
+
} = await parseBody(httpRequest.as ?? formatFromExtension(path), bytes, httpRequest.url, httpRequest);
|
|
4218
5095
|
return {
|
|
4219
5096
|
status: 200,
|
|
4220
5097
|
url: httpRequest.url,
|
|
@@ -4244,6 +5121,35 @@ async function parseBody(format, bytes, url, reading) {
|
|
|
4244
5121
|
format
|
|
4245
5122
|
};
|
|
4246
5123
|
}
|
|
5124
|
+
if (format === 'xml') {
|
|
5125
|
+
// A sitemap is often served gzipped as a file (`sitemap.xml.gz`), not as a compressed response.
|
|
5126
|
+
const {
|
|
5127
|
+
text
|
|
5128
|
+
} = decodeText(gunzipIfNeeded(bytes), reading);
|
|
5129
|
+
try {
|
|
5130
|
+
parseXml(text, url);
|
|
5131
|
+
} catch (error) {
|
|
5132
|
+
throw new Error(`${error.message}${/^\s*(?:<!doctype html|<html)/i.test(text) ? '; the body is HTML: read it with "as": "html"' : ''}`, {
|
|
5133
|
+
cause: error
|
|
5134
|
+
});
|
|
5135
|
+
}
|
|
5136
|
+
return {
|
|
5137
|
+
body: {
|
|
5138
|
+
kind: 'xml',
|
|
5139
|
+
xml: text
|
|
5140
|
+
},
|
|
5141
|
+
warnings: [],
|
|
5142
|
+
format
|
|
5143
|
+
};
|
|
5144
|
+
}
|
|
5145
|
+
if (format === 'docx') return {
|
|
5146
|
+
body: {
|
|
5147
|
+
kind: 'html',
|
|
5148
|
+
html: await readDocxHtml(bytes, url)
|
|
5149
|
+
},
|
|
5150
|
+
warnings: [],
|
|
5151
|
+
format
|
|
5152
|
+
};
|
|
4247
5153
|
if (format === 'markdown') {
|
|
4248
5154
|
const {
|
|
4249
5155
|
text
|
|
@@ -4309,24 +5215,31 @@ function looksLikeJsonLines(text) {
|
|
|
4309
5215
|
const first = parseJsonLike(lines[0]);
|
|
4310
5216
|
return 'value' in first;
|
|
4311
5217
|
}
|
|
4312
|
-
function formatFromContentType(contentType) {
|
|
5218
|
+
function formatFromContentType(contentType, url) {
|
|
4313
5219
|
const type = contentType.toLowerCase().split(';', 1)[0].trim();
|
|
5220
|
+
// A gzipped sitemap is served as an archive; its name says what is inside.
|
|
5221
|
+
if (GZIP_TYPES.has(type) && /\.xml\.gz$/i.test(new URL(url).pathname)) return 'xml';
|
|
4314
5222
|
if (CSV_TYPES.has(type)) return 'csv';
|
|
4315
5223
|
if (JSON_LINES_TYPES.has(type)) return 'jsonl';
|
|
4316
5224
|
// A legacy .xls or .ppt goes to the Office reader too, which says what to do with it.
|
|
4317
5225
|
if (type.includes('spreadsheetml') || type.startsWith('application/vnd.ms-excel')) return 'xlsx';
|
|
4318
5226
|
if (type.includes('presentationml') || type.startsWith('application/vnd.ms-powerpoint')) return 'pptx';
|
|
5227
|
+
if (type === 'application/msword' || type.includes('wordprocessingml') || type.startsWith('application/vnd.ms-word')) return 'docx';
|
|
4319
5228
|
if (YAML_TYPES.has(type)) return 'yaml';
|
|
4320
5229
|
if (type === 'text/markdown' || type === 'text/x-markdown') return 'markdown';
|
|
4321
5230
|
if (type.includes('json')) return 'json';
|
|
4322
5231
|
if (type.includes('pdf')) return 'pdf';
|
|
4323
|
-
if (type.includes('html')
|
|
5232
|
+
if (type.includes('html')) return 'html';
|
|
5233
|
+
if (type.includes('xml')) return 'xml';
|
|
4324
5234
|
return 'text';
|
|
4325
5235
|
}
|
|
4326
5236
|
const JSON_LINES_TYPES = new Set(['application/x-ndjson', 'application/ndjson', 'application/jsonl', 'application/x-jsonlines', 'application/jsonlines']);
|
|
4327
5237
|
const YAML_TYPES = new Set(['application/yaml', 'application/x-yaml', 'text/yaml', 'text/x-yaml']);
|
|
5238
|
+
const GZIP_TYPES = new Set(['application/gzip', 'application/x-gzip', 'application/octet-stream']);
|
|
4328
5239
|
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(
|
|
5240
|
+
function formatFromExtension(path) {
|
|
5241
|
+
if (/\.xml\.gz$/i.test(path)) return 'xml';
|
|
5242
|
+
const extension = extname(path);
|
|
4330
5243
|
const formats = {
|
|
4331
5244
|
'.json': 'json',
|
|
4332
5245
|
'.jsonl': 'jsonl',
|
|
@@ -4341,21 +5254,30 @@ function formatFromExtension(extension) {
|
|
|
4341
5254
|
'.pptm': 'pptx',
|
|
4342
5255
|
'.ppsx': 'pptx',
|
|
4343
5256
|
'.ppt': 'pptx',
|
|
5257
|
+
'.docx': 'docx',
|
|
5258
|
+
'.docm': 'docx',
|
|
5259
|
+
'.dotx': 'docx',
|
|
5260
|
+
'.doc': 'docx',
|
|
4344
5261
|
'.yaml': 'yaml',
|
|
4345
5262
|
'.yml': 'yaml',
|
|
4346
5263
|
'.md': 'markdown',
|
|
4347
5264
|
'.markdown': 'markdown',
|
|
4348
5265
|
'.html': 'html',
|
|
4349
5266
|
'.htm': 'html',
|
|
4350
|
-
'.xml': '
|
|
5267
|
+
'.xml': 'xml',
|
|
5268
|
+
'.rss': 'xml',
|
|
5269
|
+
'.atom': 'xml',
|
|
5270
|
+
'.kml': 'xml',
|
|
5271
|
+
'.gpx': 'xml'
|
|
4351
5272
|
};
|
|
4352
5273
|
return formats[extension.toLowerCase()] ?? 'text';
|
|
4353
5274
|
}
|
|
4354
5275
|
|
|
4355
5276
|
/**
|
|
4356
5277
|
* Sends a `request` step: renders its templates, waits for the gate's throttle,
|
|
4357
|
-
* sends,
|
|
4358
|
-
* the
|
|
5278
|
+
* sends (again, after a pause, while it fails in passing: `limits.retry`),
|
|
5279
|
+
* checks the response against the recipe's block rule, then binds it as the
|
|
5280
|
+
* scope's current document (and under the step id).
|
|
4359
5281
|
*
|
|
4360
5282
|
* @param step - The request step.
|
|
4361
5283
|
* @param scope - The scope to render in and bind into.
|
|
@@ -4368,20 +5290,29 @@ function formatFromExtension(extension) {
|
|
|
4368
5290
|
async function sendRequest(step, scope, client, recipe, gate, events) {
|
|
4369
5291
|
const lookup = path => scope.lookup(path);
|
|
4370
5292
|
const url = resolveUrl(renderText(step.url, lookup), scope.pageState?.url);
|
|
4371
|
-
|
|
5293
|
+
const request = {
|
|
5294
|
+
method: step.method,
|
|
5295
|
+
url,
|
|
5296
|
+
query: step.query === undefined ? undefined : renderMap(step.query, lookup),
|
|
5297
|
+
headers: step.headers === undefined ? undefined : renderMap(step.headers, lookup),
|
|
5298
|
+
body: renderDeep(step.body, lookup),
|
|
5299
|
+
as: step.as,
|
|
5300
|
+
encoding: step.encoding,
|
|
5301
|
+
delimiter: step.delimiter,
|
|
5302
|
+
scalars: step.scalars,
|
|
5303
|
+
timeoutMs: recipe.limits?.timeoutMs
|
|
5304
|
+
};
|
|
5305
|
+
const rule = resolveRetryRule(recipe.limits?.retry);
|
|
4372
5306
|
let response;
|
|
4373
5307
|
try {
|
|
4374
|
-
response = await
|
|
4375
|
-
|
|
4376
|
-
|
|
4377
|
-
|
|
4378
|
-
|
|
4379
|
-
|
|
4380
|
-
|
|
4381
|
-
|
|
4382
|
-
delimiter: step.delimiter,
|
|
4383
|
-
scalars: step.scalars,
|
|
4384
|
-
timeoutMs: recipe.limits?.timeoutMs
|
|
5308
|
+
response = await withTransportRetry(url, {
|
|
5309
|
+
run: () => client.send(request),
|
|
5310
|
+
problem: outcome => 'error' in outcome ? problemOf(outcome.error, rule.statuses) : undefined
|
|
5311
|
+
}, {
|
|
5312
|
+
recipeId: recipe.id,
|
|
5313
|
+
gate,
|
|
5314
|
+
events,
|
|
5315
|
+
rule
|
|
4385
5316
|
});
|
|
4386
5317
|
} catch (error) {
|
|
4387
5318
|
if (!(error instanceof HttpError)) throw error;
|
|
@@ -4431,6 +5362,14 @@ async function sendRequest(step, scope, client, recipe, gate, events) {
|
|
|
4431
5362
|
});
|
|
4432
5363
|
if (step.id !== undefined) scope.set(step.id, documentValue(response.body));
|
|
4433
5364
|
}
|
|
5365
|
+
/** A retry status (with the server's `Retry-After`), or a connection that failed. */
|
|
5366
|
+
function problemOf(error, statuses) {
|
|
5367
|
+
if (error instanceof HttpError) return statuses.includes(error.status) ? {
|
|
5368
|
+
reason: `HTTP ${error.status}`,
|
|
5369
|
+
retryAfter: error.headers['retry-after']
|
|
5370
|
+
} : undefined;
|
|
5371
|
+
return transientError(error);
|
|
5372
|
+
}
|
|
4434
5373
|
/** The class names of the widgets `session.captcha` solves. Checked only when a recipe declares it. */
|
|
4435
5374
|
const CAPTCHA_MARKUP = /\b(?:g-recaptcha|h-captcha|cf-turnstile)\b/;
|
|
4436
5375
|
function bodyText(body) {
|
|
@@ -4438,6 +5377,7 @@ function bodyText(body) {
|
|
|
4438
5377
|
if (body.kind === 'pdf') return pdfText(body);
|
|
4439
5378
|
if (body.kind === 'workbook') return workbookText(body);
|
|
4440
5379
|
if (body.kind === 'deck') return deckText(body);
|
|
5380
|
+
if (body.kind === 'xml') return body.xml;
|
|
4441
5381
|
return body.kind === 'html' ? body.html : body.text;
|
|
4442
5382
|
}
|
|
4443
5383
|
function renderMap(map, lookup) {
|
|
@@ -5713,18 +6653,34 @@ function readElements(elements) {
|
|
|
5713
6653
|
|
|
5714
6654
|
/**
|
|
5715
6655
|
* Runs a `goto` step: renders the URL (relative to the current page), waits for
|
|
5716
|
-
* the gate's throttle (`delayMs`), navigates,
|
|
5717
|
-
*
|
|
6656
|
+
* the gate's throttle (`delayMs`), navigates (again, after a pause, while it
|
|
6657
|
+
* fails in passing: `limits.retry`), records the page's real URL in the scope,
|
|
6658
|
+
* and checks the response against the recipe's block rule.
|
|
5718
6659
|
*
|
|
5719
6660
|
* @throws BlockedError when the response is a block.
|
|
5720
6661
|
*/
|
|
5721
6662
|
async function navigate(step, page, scope, recipe, gate, events) {
|
|
5722
6663
|
const target = renderText(step.url, path => scope.lookup(path));
|
|
5723
6664
|
const url = new URL(target, scope.pageState?.url ?? page.url()).href;
|
|
5724
|
-
|
|
5725
|
-
const response = await
|
|
5726
|
-
|
|
5727
|
-
|
|
6665
|
+
const rule = resolveRetryRule(recipe.limits?.retry);
|
|
6666
|
+
const response = await withTransportRetry(url, {
|
|
6667
|
+
run: () => page.goto(url, {
|
|
6668
|
+
waitUntil: step.waitUntil,
|
|
6669
|
+
timeout: recipe.limits?.timeoutMs
|
|
6670
|
+
}),
|
|
6671
|
+
problem: outcome => {
|
|
6672
|
+
if ('error' in outcome) return transientError(outcome.error);
|
|
6673
|
+
const status = outcome.value?.status();
|
|
6674
|
+
return status !== undefined && rule.statuses.includes(status) ? {
|
|
6675
|
+
reason: `HTTP ${status}`,
|
|
6676
|
+
retryAfter: outcome.value?.headers()['retry-after']
|
|
6677
|
+
} : undefined;
|
|
6678
|
+
}
|
|
6679
|
+
}, {
|
|
6680
|
+
recipeId: recipe.id,
|
|
6681
|
+
gate,
|
|
6682
|
+
events,
|
|
6683
|
+
rule
|
|
5728
6684
|
});
|
|
5729
6685
|
scope.setPage({
|
|
5730
6686
|
url: page.url()
|
|
@@ -5874,8 +6830,13 @@ class WebStepRunner {
|
|
|
5874
6830
|
const link = this.page.locator(next.selector).first();
|
|
5875
6831
|
if (!(await appears(link, NEXT_LINK_TIMEOUT_MS))) return null;
|
|
5876
6832
|
const before = this.page.url();
|
|
5877
|
-
await
|
|
5878
|
-
|
|
6833
|
+
const release = await this.gate.request(before);
|
|
6834
|
+
try {
|
|
6835
|
+
await link.click();
|
|
6836
|
+
await this.page.waitForLoadState();
|
|
6837
|
+
} finally {
|
|
6838
|
+
release();
|
|
6839
|
+
}
|
|
5879
6840
|
if (this.page.url() === before) await this.page.waitForTimeout(NEXT_LINK_TIMEOUT_MS / 4);
|
|
5880
6841
|
this.events.emit({
|
|
5881
6842
|
type: 'page:visit',
|
|
@@ -5889,6 +6850,23 @@ class WebStepRunner {
|
|
|
5889
6850
|
url: this.page.url()
|
|
5890
6851
|
};
|
|
5891
6852
|
}
|
|
6853
|
+
/**
|
|
6854
|
+
* A runner on a new tab of the same context, for one parallel iteration:
|
|
6855
|
+
* it shares cookies, the gate and the captcha guard; disposing it closes the tab only.
|
|
6856
|
+
*
|
|
6857
|
+
* @returns The forked runner.
|
|
6858
|
+
*/
|
|
6859
|
+
async fork() {
|
|
6860
|
+
const {
|
|
6861
|
+
context
|
|
6862
|
+
} = this.session;
|
|
6863
|
+
const page = await context.newPage();
|
|
6864
|
+
const viewport = this.recipe.session?.viewport;
|
|
6865
|
+
if (viewport !== undefined) await page.setViewportSize(viewport);
|
|
6866
|
+
return new WebStepRunner(new BrowserSession(context, page, async () => {
|
|
6867
|
+
await page.close();
|
|
6868
|
+
}), this.recipe, this.events, this.gate, this.captcha);
|
|
6869
|
+
}
|
|
5892
6870
|
async elements(selector) {
|
|
5893
6871
|
return snapshotElements(selector, this.page);
|
|
5894
6872
|
}
|
|
@@ -5925,16 +6903,30 @@ function accessOptions(lease, headers) {
|
|
|
5925
6903
|
* The bootstrap runs through the same access lease as the crawl that follows,
|
|
5926
6904
|
* so a login and the requests that use its cookies come from one IP.
|
|
5927
6905
|
*
|
|
6906
|
+
* With `session.browserProfile`, the bootstrap runs in that profile, and
|
|
6907
|
+
* without a bootstrap the profile's own cookies and storage are the state: an
|
|
6908
|
+
* api recipe picks up a login a browser left in the profile.
|
|
6909
|
+
*
|
|
5928
6910
|
* @param recipe - The input recipe.
|
|
5929
6911
|
* @param deps - Browser, hooks, events.
|
|
5930
6912
|
* @param lease - The recipe run's access; direct when omitted.
|
|
5931
6913
|
* @param captcha - Solves the bootstrap's captchas (a login form's).
|
|
6914
|
+
* @param owner - The recipe run, which a browser profile is held by.
|
|
5932
6915
|
* @returns The state, or `undefined` when the recipe declares none.
|
|
5933
6916
|
*/
|
|
5934
|
-
async function resolveStorageState(recipe, deps, lease, captcha) {
|
|
6917
|
+
async function resolveStorageState(recipe, deps, lease, captcha, owner = {}) {
|
|
5935
6918
|
const saved = await readSavedState(recipe, deps);
|
|
6919
|
+
if (saved !== undefined) return saved;
|
|
5936
6920
|
const session = recipe.session;
|
|
5937
|
-
if (
|
|
6921
|
+
if (session?.browserProfile !== undefined) {
|
|
6922
|
+
const browserSession = await openBrowserProfile(recipe, deps, lease, owner);
|
|
6923
|
+
try {
|
|
6924
|
+
return session.bootstrap === undefined ? await browserSession.storageState() : await runBootstrap(recipe, browserSession, deps, captcha);
|
|
6925
|
+
} finally {
|
|
6926
|
+
await browserSession.close();
|
|
6927
|
+
}
|
|
6928
|
+
}
|
|
6929
|
+
if (session?.bootstrap === undefined) return undefined;
|
|
5938
6930
|
const browser = await deps.browser();
|
|
5939
6931
|
const browserSession = await browser.newSession({
|
|
5940
6932
|
cookies: session.cookies,
|
|
@@ -5948,6 +6940,27 @@ async function resolveStorageState(recipe, deps, lease, captcha) {
|
|
|
5948
6940
|
await browserSession.close();
|
|
5949
6941
|
}
|
|
5950
6942
|
}
|
|
6943
|
+
/**
|
|
6944
|
+
* Opens the recipe's `session.browserProfile` with its session options and
|
|
6945
|
+
* the lease's proxy.
|
|
6946
|
+
*
|
|
6947
|
+
* @param recipe - A recipe with `session.browserProfile`.
|
|
6948
|
+
* @param deps - For `profiles`.
|
|
6949
|
+
* @param lease - The access lease.
|
|
6950
|
+
* @param owner - The recipe run.
|
|
6951
|
+
* @returns The session in the profile.
|
|
6952
|
+
*/
|
|
6953
|
+
async function openBrowserProfile(recipe, deps, lease, owner) {
|
|
6954
|
+
const session = recipe.session;
|
|
6955
|
+
const name = session?.browserProfile ?? '';
|
|
6956
|
+
if (deps.profiles === undefined) throw new Error(`recipe "${recipe.id}" uses browser profile "${name}", but this crawler has no profiles directory (CrawlOptions.profilesDir)`);
|
|
6957
|
+
return deps.profiles.open(name, {
|
|
6958
|
+
cookies: session?.cookies,
|
|
6959
|
+
userAgent: session?.userAgent,
|
|
6960
|
+
viewport: session?.viewport,
|
|
6961
|
+
...accessOptions(lease, session?.headers)
|
|
6962
|
+
}, owner);
|
|
6963
|
+
}
|
|
5951
6964
|
/**
|
|
5952
6965
|
* The storage state saved by an earlier bootstrap (`session.storageStatePath`), if the recipe names one.
|
|
5953
6966
|
*
|
|
@@ -5977,7 +6990,7 @@ async function runBootstrap(recipe, browserSession, deps, captcha) {
|
|
|
5977
6990
|
cookies: [],
|
|
5978
6991
|
origins: []
|
|
5979
6992
|
};
|
|
5980
|
-
const runner = new WebStepRunner(browserSession, recipe, deps.events,
|
|
6993
|
+
const runner = new WebStepRunner(browserSession, recipe, deps.events, new RunGate(1, recipe.limits?.delayMs ?? 0, deps.hosts), captcha);
|
|
5981
6994
|
const scope = new ExtractionScope();
|
|
5982
6995
|
scope.set('vars', recipe.vars ?? {});
|
|
5983
6996
|
scope.set('start', {
|
|
@@ -6082,6 +7095,66 @@ class RotatingRunner {
|
|
|
6082
7095
|
throw error;
|
|
6083
7096
|
}
|
|
6084
7097
|
}
|
|
7098
|
+
/**
|
|
7099
|
+
* A runner for one parallel iteration, forked from whichever runner is
|
|
7100
|
+
* current when it runs a step: after a rotation it forks again from the new
|
|
7101
|
+
* one, since the old context is gone (or going). Blocks are noted and
|
|
7102
|
+
* rotated like the main runner's.
|
|
7103
|
+
*
|
|
7104
|
+
* @returns The iteration's runner.
|
|
7105
|
+
*/
|
|
7106
|
+
async fork() {
|
|
7107
|
+
// `own`: a tab this iteration opened and must close; an api runner is shared and never disposed here.
|
|
7108
|
+
let forked;
|
|
7109
|
+
const disposeForked = async () => {
|
|
7110
|
+
if (forked?.own === true) await disposeQuietly(forked.runner);
|
|
7111
|
+
forked = undefined;
|
|
7112
|
+
};
|
|
7113
|
+
const current = async () => {
|
|
7114
|
+
if (forked?.generation === this.generation) return forked.runner;
|
|
7115
|
+
await disposeForked();
|
|
7116
|
+
const inner = this.inner;
|
|
7117
|
+
forked = inner.fork === undefined ? {
|
|
7118
|
+
generation: this.generation,
|
|
7119
|
+
runner: inner,
|
|
7120
|
+
own: false
|
|
7121
|
+
} : {
|
|
7122
|
+
generation: this.generation,
|
|
7123
|
+
runner: await inner.fork(),
|
|
7124
|
+
own: true
|
|
7125
|
+
};
|
|
7126
|
+
return forked.runner;
|
|
7127
|
+
};
|
|
7128
|
+
return {
|
|
7129
|
+
runLeaf: async (step, scope) => {
|
|
7130
|
+
const generation = this.generation;
|
|
7131
|
+
try {
|
|
7132
|
+
const runner = await current();
|
|
7133
|
+
await runner.runLeaf(step, scope);
|
|
7134
|
+
} catch (error) {
|
|
7135
|
+
this.note(error, generation);
|
|
7136
|
+
throw error;
|
|
7137
|
+
}
|
|
7138
|
+
},
|
|
7139
|
+
nextPage: async (next, scope) => {
|
|
7140
|
+
const generation = this.generation;
|
|
7141
|
+
try {
|
|
7142
|
+
const runner = await current();
|
|
7143
|
+
return await runner.nextPage(next, scope);
|
|
7144
|
+
} catch (error) {
|
|
7145
|
+
this.note(error, generation);
|
|
7146
|
+
throw error;
|
|
7147
|
+
}
|
|
7148
|
+
},
|
|
7149
|
+
elements: async (selector, scope) => {
|
|
7150
|
+
const runner = await current();
|
|
7151
|
+
if (runner.elements === undefined) throw new Error('forEach over selector iterates live elements and needs a browser; this recipe runs in api mode');
|
|
7152
|
+
return runner.elements(selector, scope);
|
|
7153
|
+
},
|
|
7154
|
+
rotate: error => this.rotate(error),
|
|
7155
|
+
dispose: disposeForked
|
|
7156
|
+
};
|
|
7157
|
+
}
|
|
6085
7158
|
async elements(selector, scope) {
|
|
6086
7159
|
if (this.inner.elements === undefined) throw new Error('forEach over selector iterates live elements and needs a browser; this recipe runs in api mode');
|
|
6087
7160
|
return this.inner.elements(selector, scope);
|
|
@@ -6130,12 +7203,19 @@ class RotatingRunner {
|
|
|
6130
7203
|
* the sink sees one record at a time and `maxRecords` is exact: once reached,
|
|
6131
7204
|
* every later emit returns `stop` before mapping.
|
|
6132
7205
|
*
|
|
6133
|
-
* @param
|
|
7206
|
+
* @param recipe - The input recipe.
|
|
6134
7207
|
* @param output - The output recipe it feeds.
|
|
6135
7208
|
* @param deps - Shared browser, hooks, events, sink and de-duplication.
|
|
6136
7209
|
* @returns What happened.
|
|
6137
7210
|
*/
|
|
6138
|
-
async function runInputRecipe(
|
|
7211
|
+
async function runInputRecipe(recipe, output, deps) {
|
|
7212
|
+
const input = {
|
|
7213
|
+
...recipe,
|
|
7214
|
+
limits: {
|
|
7215
|
+
...recipe.limits,
|
|
7216
|
+
retry: resolveRetryRule(recipe.limits?.retry, deps.retry)
|
|
7217
|
+
}
|
|
7218
|
+
};
|
|
6139
7219
|
const started = Date.now();
|
|
6140
7220
|
const report = {
|
|
6141
7221
|
recipeId: input.id,
|
|
@@ -6154,8 +7234,8 @@ async function runInputRecipe(input, output, deps) {
|
|
|
6154
7234
|
failed: 0
|
|
6155
7235
|
};
|
|
6156
7236
|
const limits = input.limits ?? {};
|
|
6157
|
-
//
|
|
6158
|
-
const gate = new RunGate(
|
|
7237
|
+
// Parallel iterations: requests in api mode, tabs of the recipe's context in web mode.
|
|
7238
|
+
const gate = new RunGate(limits.concurrency ?? 1, limits.delayMs ?? 0, deps.hosts);
|
|
6159
7239
|
let stopped = false;
|
|
6160
7240
|
let chain = Promise.resolve();
|
|
6161
7241
|
const unsubscribe = deps.events.subscribe(event => {
|
|
@@ -6170,7 +7250,7 @@ async function runInputRecipe(input, output, deps) {
|
|
|
6170
7250
|
recipeId: input.id,
|
|
6171
7251
|
mode: input.mode
|
|
6172
7252
|
});
|
|
6173
|
-
deps.dedupe.
|
|
7253
|
+
const dedupe = deps.dedupe.forRecipe();
|
|
6174
7254
|
let runner;
|
|
6175
7255
|
try {
|
|
6176
7256
|
const onBlock = input.session?.onBlock;
|
|
@@ -6278,7 +7358,7 @@ async function runInputRecipe(input, output, deps) {
|
|
|
6278
7358
|
url,
|
|
6279
7359
|
key: record.key
|
|
6280
7360
|
});
|
|
6281
|
-
} else if (
|
|
7361
|
+
} else if (dedupe.isDuplicate(record)) {
|
|
6282
7362
|
report.duplicates += 1;
|
|
6283
7363
|
deps.events.emit({
|
|
6284
7364
|
type: 'record:duplicate',
|
|
@@ -6370,7 +7450,8 @@ async function openRunner(input, deps, context, lease) {
|
|
|
6370
7450
|
lease
|
|
6371
7451
|
});
|
|
6372
7452
|
if (lease.cdp !== undefined) return openRemoteRunner(input, deps, context, lease, lease.cdp);
|
|
6373
|
-
|
|
7453
|
+
if (input.mode === 'web' && input.session?.browserProfile !== undefined) return openProfileRunner(input, deps, context, lease, captcha);
|
|
7454
|
+
const storageState = await resolveStorageState(input, deps, lease, captcha, context);
|
|
6374
7455
|
const session = input.session;
|
|
6375
7456
|
const access = accessOptions(lease, session?.headers);
|
|
6376
7457
|
if (input.mode === 'web') {
|
|
@@ -6394,6 +7475,28 @@ async function openRunner(input, deps, context, lease) {
|
|
|
6394
7475
|
});
|
|
6395
7476
|
return new ApiStepRunner(client, input, deps.events, gate);
|
|
6396
7477
|
}
|
|
7478
|
+
/**
|
|
7479
|
+
* A web runner in a persistent browser profile. The bootstrap runs in the
|
|
7480
|
+
* same browser as the crawl, and what both leave behind (cookies, storage)
|
|
7481
|
+
* stays in the profile for the next run.
|
|
7482
|
+
*/
|
|
7483
|
+
async function openProfileRunner(input, deps, context, lease, captcha) {
|
|
7484
|
+
const saved = await readSavedState(input, deps);
|
|
7485
|
+
const browserSession = await openBrowserProfile(saved === undefined ? input : {
|
|
7486
|
+
...input,
|
|
7487
|
+
session: {
|
|
7488
|
+
...input.session,
|
|
7489
|
+
cookies: [...saved.cookies, ...(input.session?.cookies ?? [])]
|
|
7490
|
+
}
|
|
7491
|
+
}, deps, lease, context);
|
|
7492
|
+
try {
|
|
7493
|
+
if (saved === undefined && input.session?.bootstrap !== undefined) await runBootstrap(input, browserSession, deps, captcha);
|
|
7494
|
+
} catch (error) {
|
|
7495
|
+
await browserSession.close();
|
|
7496
|
+
throw error;
|
|
7497
|
+
}
|
|
7498
|
+
return new WebStepRunner(browserSession, input, deps.events, context.gate, captcha);
|
|
7499
|
+
}
|
|
6397
7500
|
/**
|
|
6398
7501
|
* A web runner in a remote browser. The bootstrap runs in the same remote
|
|
6399
7502
|
* session as the crawl: providers tie the IP and fingerprint to the
|
|
@@ -6408,6 +7511,7 @@ async function openRemoteRunner(input, deps, context, lease, cdp) {
|
|
|
6408
7511
|
lease
|
|
6409
7512
|
});
|
|
6410
7513
|
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`);
|
|
7514
|
+
if (input.session?.browserProfile !== undefined) throw new AccessConfigError(`recipe "${input.id}" uses browser profile "${input.session.browserProfile}", which needs a local browser, but access profile "${lease.profile}" is a remote browser`);
|
|
6411
7515
|
const session = input.session;
|
|
6412
7516
|
const storageState = await readSavedState(input, deps);
|
|
6413
7517
|
const browserSession = await BrowserClient.connectOverCDP(cdp, {
|
|
@@ -6426,27 +7530,42 @@ async function openRemoteRunner(input, deps, context, lease, cdp) {
|
|
|
6426
7530
|
}
|
|
6427
7531
|
|
|
6428
7532
|
/**
|
|
6429
|
-
* Runs every input recipe of a set
|
|
7533
|
+
* Runs every input recipe of a set into one sink, `parallel` at a time
|
|
7534
|
+
* (default one after another). Reports come back in the set's order whatever
|
|
7535
|
+
* order the recipes finish in. Under `onRecipeError: 'stop'`, a failed recipe
|
|
7536
|
+
* stops the ones not started yet; those already running finish.
|
|
6430
7537
|
*
|
|
6431
7538
|
* @param set - The bound recipes.
|
|
6432
7539
|
* @param deps - Shared browser, hooks, events, sink and de-duplication.
|
|
6433
7540
|
* @param onRecipeError - Whether a failed recipe stops the run.
|
|
7541
|
+
* @param parallel - How many input recipes run at once.
|
|
6434
7542
|
* @returns The report.
|
|
6435
7543
|
*/
|
|
6436
|
-
async function runCrawl(set, deps, onRecipeError) {
|
|
7544
|
+
async function runCrawl(set, deps, onRecipeError, parallel = 1) {
|
|
6437
7545
|
const started = Date.now();
|
|
6438
7546
|
await deps.sink.open(set.output);
|
|
6439
|
-
const
|
|
7547
|
+
const reports = [];
|
|
6440
7548
|
let sink;
|
|
6441
7549
|
try {
|
|
6442
|
-
|
|
6443
|
-
|
|
6444
|
-
|
|
6445
|
-
|
|
6446
|
-
|
|
7550
|
+
let next = 0;
|
|
7551
|
+
let stopped = false;
|
|
7552
|
+
const lane = async () => {
|
|
7553
|
+
while (!stopped && next < set.inputs.length) {
|
|
7554
|
+
const index = next;
|
|
7555
|
+
next += 1;
|
|
7556
|
+
const report = await runInputRecipe(set.inputs[index], set.output, deps);
|
|
7557
|
+
reports[index] = report;
|
|
7558
|
+
if (onRecipeError === 'stop' && report.error !== undefined) stopped = true;
|
|
7559
|
+
}
|
|
7560
|
+
};
|
|
7561
|
+
const lanes = Math.max(1, Math.min(parallel, set.inputs.length));
|
|
7562
|
+
await Promise.all(Array.from({
|
|
7563
|
+
length: lanes
|
|
7564
|
+
}, lane));
|
|
6447
7565
|
} finally {
|
|
6448
7566
|
sink = await deps.sink.close();
|
|
6449
7567
|
}
|
|
7568
|
+
const recipes = reports.filter(report => report !== undefined);
|
|
6450
7569
|
return {
|
|
6451
7570
|
outputId: set.output.id,
|
|
6452
7571
|
recipes,
|
|
@@ -6471,6 +7590,8 @@ function createCrawler(options = {}) {
|
|
|
6471
7590
|
const access = new AccessBroker(options.access, options.accessPlugins);
|
|
6472
7591
|
const hooks = new HookRegistry(options.hooks);
|
|
6473
7592
|
const captchaSolvers = new CaptchaSolverRegistry(options.captchaSolvers);
|
|
7593
|
+
const hosts = new HostThrottle(options.throttle ?? options.access?.throttle);
|
|
7594
|
+
const profiles = new BrowserProfiles(options.profilesDir ?? resolve$1(options.storageStateDir ?? '.', '.opencraw', 'profiles'), options.browser);
|
|
6474
7595
|
const events = new EventBus(options.onEvent);
|
|
6475
7596
|
let browser;
|
|
6476
7597
|
const launch = () => {
|
|
@@ -6489,8 +7610,11 @@ function createCrawler(options = {}) {
|
|
|
6489
7610
|
debug: options.debug === true,
|
|
6490
7611
|
access,
|
|
6491
7612
|
captchaSolvers,
|
|
7613
|
+
hosts,
|
|
7614
|
+
profiles,
|
|
7615
|
+
retry: options.retry,
|
|
6492
7616
|
ignoreHTTPSErrors: options.browser?.ignoreHTTPSErrors
|
|
6493
|
-
}, options.onRecipeError ?? 'continue'),
|
|
7617
|
+
}, options.onRecipeError ?? 'continue', options.parallel ?? 1),
|
|
6494
7618
|
async close() {
|
|
6495
7619
|
const launched = browser;
|
|
6496
7620
|
browser = undefined;
|
|
@@ -6506,7 +7630,7 @@ const CRAWL_MODES = ['web', 'api'];
|
|
|
6506
7630
|
const SELECTOR_KINDS = ['css', 'xpath', 'jsonpath', 'regex', 'table'];
|
|
6507
7631
|
/** `take` also accepts `attr:<name>`, which is validated by pattern rather than listed. */
|
|
6508
7632
|
const TAKE_KINDS = ['text', 'html', 'value', 'json'];
|
|
6509
|
-
const BODY_KINDS = ['json', 'jsonl', 'html', 'text', 'pdf', 'csv', 'xlsx', 'pptx', 'yaml', 'markdown'];
|
|
7633
|
+
const BODY_KINDS = ['json', 'jsonl', 'html', 'text', 'pdf', 'csv', 'xlsx', 'pptx', 'yaml', 'markdown', 'xml', 'docx'];
|
|
6510
7634
|
const YAML_SCALARS = ['typed', 'text'];
|
|
6511
7635
|
/** How a PDF table aligns a row's values against a cell wrapped over several lines. */
|
|
6512
7636
|
const TABLE_ALIGNS = ['auto', 'top', 'center', 'bottom'];
|
|
@@ -6712,8 +7836,20 @@ const extractStep = z.strictObject({
|
|
|
6712
7836
|
fillDown: z.array(z.string().min(1)).min(1).optional(),
|
|
6713
7837
|
includeHidden: z.boolean().optional(),
|
|
6714
7838
|
slide: z.string().min(1).optional(),
|
|
6715
|
-
shapes: z.boolean().optional()
|
|
7839
|
+
shapes: z.boolean().optional(),
|
|
7840
|
+
namespaces: z.record(z.string().regex(/^[A-Z_][\w.-]*$/i, 'a namespace prefix such as atom'), z.string().min(1)).optional(),
|
|
7841
|
+
ignoreNamespaces: z.boolean().optional()
|
|
6716
7842
|
}).check(context => {
|
|
7843
|
+
if (context.value.kind !== 'xpath') {
|
|
7844
|
+
for (const key of ['namespaces', 'ignoreNamespaces']) {
|
|
7845
|
+
if (context.value[key] !== undefined) context.issues.push({
|
|
7846
|
+
code: 'custom',
|
|
7847
|
+
input: context.value,
|
|
7848
|
+
path: [key],
|
|
7849
|
+
message: `"${key}" belongs to kind "xpath"`
|
|
7850
|
+
});
|
|
7851
|
+
}
|
|
7852
|
+
}
|
|
6717
7853
|
if (context.value.kind === 'table') return;
|
|
6718
7854
|
for (const key of tableOnly) {
|
|
6719
7855
|
if (context.value[key] !== undefined) context.issues.push({
|
|
@@ -6962,13 +8098,21 @@ const sessionSpecSchema = z.strictObject({
|
|
|
6962
8098
|
access: sessionAccessSchema.optional(),
|
|
6963
8099
|
blockedWhen: blockRuleSchema.optional(),
|
|
6964
8100
|
onBlock: blockRotationSchema.optional(),
|
|
6965
|
-
captcha: captchaSettingsSchema.optional()
|
|
8101
|
+
captcha: captchaSettingsSchema.optional(),
|
|
8102
|
+
browserProfile: z.string().regex(/^[\w-]+$/, 'a browser profile name is letters, digits, hyphens and underscores').optional()
|
|
8103
|
+
});
|
|
8104
|
+
const retryRuleSchema = z.strictObject({
|
|
8105
|
+
attempts: z.int().min(1).max(10).optional(),
|
|
8106
|
+
backoffMs: z.int().nonnegative().optional(),
|
|
8107
|
+
maxDelayMs: z.int().nonnegative().optional(),
|
|
8108
|
+
statuses: z.array(z.int().min(400).max(599)).optional()
|
|
6966
8109
|
});
|
|
6967
8110
|
const limitsSchema = z.strictObject({
|
|
6968
8111
|
maxRecords: z.int().positive().optional(),
|
|
6969
8112
|
delayMs: z.int().nonnegative().optional(),
|
|
6970
8113
|
timeoutMs: z.int().positive().optional(),
|
|
6971
|
-
concurrency: z.int().min(1).max(64).optional()
|
|
8114
|
+
concurrency: z.int().min(1).max(64).optional(),
|
|
8115
|
+
retry: retryRuleSchema.optional()
|
|
6972
8116
|
});
|
|
6973
8117
|
const inputRecipeSchema = z.strictObject({
|
|
6974
8118
|
$schema: z.string().optional(),
|
|
@@ -7499,5 +8643,176 @@ function isSameOutput(document, output) {
|
|
|
7499
8643
|
return recipeKindOf(document.content) === 'output' && document.content.id === output.id;
|
|
7500
8644
|
}
|
|
7501
8645
|
|
|
7502
|
-
|
|
8646
|
+
const BOOKKEEPING = new Set(['_source', '_key']);
|
|
8647
|
+
/**
|
|
8648
|
+
* Compares two runs' records by key: what was added, what was removed, and
|
|
8649
|
+
* for the records in both, which fields changed, before and after. Like a
|
|
8650
|
+
* `git diff` of a price list, but row by row instead of line by line, so a
|
|
8651
|
+
* reordered file is not a change.
|
|
8652
|
+
*
|
|
8653
|
+
* @param previous - The earlier run's records.
|
|
8654
|
+
* @param current - The later run's records.
|
|
8655
|
+
* @param options - The key fields, the fields to ignore, the shrink threshold.
|
|
8656
|
+
* @returns The differences.
|
|
8657
|
+
* @throws Error when records carry no key: without `key`, every line needs `_key`.
|
|
8658
|
+
*/
|
|
8659
|
+
function diffRecords(previous, current, options = {}) {
|
|
8660
|
+
const ignore = new Set([...BOOKKEEPING, ...(options.ignore ?? [])]);
|
|
8661
|
+
const keyOf = keyReader(options.key);
|
|
8662
|
+
let repeated = 0;
|
|
8663
|
+
const index = (records, side) => {
|
|
8664
|
+
const byKey = new Map();
|
|
8665
|
+
for (const [line, record] of records.entries()) {
|
|
8666
|
+
const key = keyOf(record, `${side} record ${line + 1}`);
|
|
8667
|
+
if (byKey.has(key)) repeated += 1;else byKey.set(key, record);
|
|
8668
|
+
}
|
|
8669
|
+
return byKey;
|
|
8670
|
+
};
|
|
8671
|
+
const before = index(previous, 'previous');
|
|
8672
|
+
const after = index(current, 'current');
|
|
8673
|
+
const removed = [];
|
|
8674
|
+
const changed = [];
|
|
8675
|
+
const added = [];
|
|
8676
|
+
let unchanged = 0;
|
|
8677
|
+
for (const [key, record] of before) {
|
|
8678
|
+
const now = after.get(key);
|
|
8679
|
+
if (now === undefined) {
|
|
8680
|
+
removed.push({
|
|
8681
|
+
change: 'removed',
|
|
8682
|
+
key,
|
|
8683
|
+
before: record
|
|
8684
|
+
});
|
|
8685
|
+
continue;
|
|
8686
|
+
}
|
|
8687
|
+
const fields = fieldChanges(record, now, ignore, '');
|
|
8688
|
+
if (fields.length === 0) unchanged += 1;else changed.push({
|
|
8689
|
+
change: 'changed',
|
|
8690
|
+
key,
|
|
8691
|
+
before: record,
|
|
8692
|
+
after: now,
|
|
8693
|
+
fields
|
|
8694
|
+
});
|
|
8695
|
+
}
|
|
8696
|
+
for (const [key, record] of after) {
|
|
8697
|
+
if (!before.has(key)) added.push({
|
|
8698
|
+
change: 'added',
|
|
8699
|
+
key,
|
|
8700
|
+
after: record
|
|
8701
|
+
});
|
|
8702
|
+
}
|
|
8703
|
+
const threshold = options.shrink ?? 0.5;
|
|
8704
|
+
const shrunk = previous.length > 0 && current.length < previous.length * (1 - threshold);
|
|
8705
|
+
return {
|
|
8706
|
+
added: added.length,
|
|
8707
|
+
removed: removed.length,
|
|
8708
|
+
changed: changed.length,
|
|
8709
|
+
unchanged,
|
|
8710
|
+
changes: [...removed, ...changed, ...added],
|
|
8711
|
+
repeated,
|
|
8712
|
+
counts: {
|
|
8713
|
+
previous: previous.length,
|
|
8714
|
+
current: current.length
|
|
8715
|
+
},
|
|
8716
|
+
...(shrunk && {
|
|
8717
|
+
shrunk: {
|
|
8718
|
+
previous: previous.length,
|
|
8719
|
+
current: current.length
|
|
8720
|
+
}
|
|
8721
|
+
})
|
|
8722
|
+
};
|
|
8723
|
+
}
|
|
8724
|
+
/**
|
|
8725
|
+
* The diff options an output recipe implies: its key fields, and its fields
|
|
8726
|
+
* the engine fills differently every run (`generated: now`, `uuid`).
|
|
8727
|
+
*
|
|
8728
|
+
* @param output - The output recipe.
|
|
8729
|
+
* @returns The key and the fields to ignore.
|
|
8730
|
+
*/
|
|
8731
|
+
function diffOptionsFor(output) {
|
|
8732
|
+
const fields = Object.entries(output.fields);
|
|
8733
|
+
return {
|
|
8734
|
+
key: fields.filter(([, field]) => field.key === true).map(([name]) => name),
|
|
8735
|
+
ignore: fields.filter(([, field]) => field.generated === 'now' || field.generated === 'uuid').map(([name]) => name)
|
|
8736
|
+
};
|
|
8737
|
+
}
|
|
8738
|
+
/**
|
|
8739
|
+
* A key as people read it: the key fields' values joined (`Pandina · 1.0 Hybrid`).
|
|
8740
|
+
*
|
|
8741
|
+
* @param key - A record key (a JSON array of the key values).
|
|
8742
|
+
* @returns The readable form.
|
|
8743
|
+
*/
|
|
8744
|
+
function readableKey(key) {
|
|
8745
|
+
try {
|
|
8746
|
+
const values = JSON.parse(key);
|
|
8747
|
+
return Array.isArray(values) ? values.map(value => typeof value === 'string' ? value : JSON.stringify(value)).join(' · ') : key;
|
|
8748
|
+
} catch {
|
|
8749
|
+
return key;
|
|
8750
|
+
}
|
|
8751
|
+
}
|
|
8752
|
+
function keyReader(fields) {
|
|
8753
|
+
if (fields !== undefined && fields.length > 0) return record => JSON.stringify(fields.map(field => record[field] ?? null));
|
|
8754
|
+
return storedKey;
|
|
8755
|
+
}
|
|
8756
|
+
/** The `_key` a sink in append mode wrote on the line. */
|
|
8757
|
+
function storedKey(record, where) {
|
|
8758
|
+
const key = record._key;
|
|
8759
|
+
if (typeof key !== 'string') throw new Error(`${where} has no _key: name the fields that identify a record (--key model,version, or the output recipe's key fields)`);
|
|
8760
|
+
return key;
|
|
8761
|
+
}
|
|
8762
|
+
function fieldChanges(before, after, ignore, prefix) {
|
|
8763
|
+
const names = [...new Set([...Object.keys(before), ...Object.keys(after)])];
|
|
8764
|
+
const changes = [];
|
|
8765
|
+
for (const name of names) {
|
|
8766
|
+
const field = `${prefix}${name}`;
|
|
8767
|
+
if (ignore.has(field) || prefix === '' && ignore.has(name)) continue;
|
|
8768
|
+
const was = before[name];
|
|
8769
|
+
const is = after[name];
|
|
8770
|
+
if (isPlainObject(was) && isPlainObject(is)) changes.push(...fieldChanges(was, is, ignore, `${field}.`));else if (!sameValue(was, is)) changes.push({
|
|
8771
|
+
field,
|
|
8772
|
+
before: was ?? null,
|
|
8773
|
+
after: is ?? null
|
|
8774
|
+
});
|
|
8775
|
+
}
|
|
8776
|
+
return changes;
|
|
8777
|
+
}
|
|
8778
|
+
function isPlainObject(value) {
|
|
8779
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
8780
|
+
}
|
|
8781
|
+
/** Equal as JSON, whatever the order of object keys. */
|
|
8782
|
+
function sameValue(first, second) {
|
|
8783
|
+
return stableJson(first ?? null) === stableJson(second ?? null);
|
|
8784
|
+
}
|
|
8785
|
+
function stableJson(value) {
|
|
8786
|
+
if (Array.isArray(value)) return `[${value.map(entry => stableJson(entry)).join(',')}]`;
|
|
8787
|
+
if (isPlainObject(value)) return `{${Object.keys(value).sort((first, second) => first.localeCompare(second)).map(key => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
|
|
8788
|
+
return JSON.stringify(value) ?? 'null';
|
|
8789
|
+
}
|
|
8790
|
+
|
|
8791
|
+
/**
|
|
8792
|
+
* The records of a JSON Lines file, as a sink wrote them.
|
|
8793
|
+
*
|
|
8794
|
+
* @param path - The file.
|
|
8795
|
+
* @returns One record per non-empty line.
|
|
8796
|
+
* @throws Error naming the file and the line that is not a JSON object.
|
|
8797
|
+
*/
|
|
8798
|
+
async function readRecordsFile(path) {
|
|
8799
|
+
const text = await readFile(path, 'utf8');
|
|
8800
|
+
const records = [];
|
|
8801
|
+
for (const [index, line] of text.split(/\r?\n/).entries()) {
|
|
8802
|
+
if (line.trim() === '') continue;
|
|
8803
|
+
let value;
|
|
8804
|
+
try {
|
|
8805
|
+
value = JSON.parse(line);
|
|
8806
|
+
} catch (error) {
|
|
8807
|
+
throw new Error(`${path}:${index + 1}: not JSON (${error.message})`, {
|
|
8808
|
+
cause: error
|
|
8809
|
+
});
|
|
8810
|
+
}
|
|
8811
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error(`${path}:${index + 1}: not a record (a JSON object)`);
|
|
8812
|
+
records.push(value);
|
|
8813
|
+
}
|
|
8814
|
+
return records;
|
|
8815
|
+
}
|
|
8816
|
+
|
|
8817
|
+
export { ACCESS_PRESETS, AccessBroker, AccessConfigError, BrowserClient, BrowserSession, CaptchaError, DEFAULT_CAPTCHA_SELECTOR, DEFAULT_RETRY_RULE, HostThrottle, HttpClient, HttpError, MappingFailedError, PdfReadError, RecipeBindingError, RecipeSet, RecipeValidationError, RecordRejectedError, StepFailure, TransformError, UnknownHookError, accessConfigJsonSchema, accessConfigSchema, bindRecipeSet, createCrawler, csvWorkbook, deckText, detectChallenge, detectDelimiter, diffOptionsFor, diffRecords, fillDown, findDeckTables, findGridTables, findTables, htmlTableSheets, inputRecipeJsonSchema, inputRecipeSchema, isDeckDocument, isWorkbookDocument, isXmlDocument, jsonLinesSink, loadAccessConfig, loadRecipeSet, loadRecipes, memorySink, outputRecipeJsonSchema, outputRecipeSchema, parseCsv, parseInputRecipe, parseOutputRecipe, parseXml, pdfText, readMarkdown, readPdf, readRecipeSource, readRecordsFile, readYaml, readableKey, retryRuleSchema, selectXpath, takeFromXml, throttleConfigSchema, traceLine, tryParseJson, validateBinding, workbookText };
|
|
7503
8818
|
//# sourceMappingURL=index.esm.js.map
|