@opencraw/studio 0.0.30 → 0.0.32
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/dist/document-view/pdf-view.mapper.d.ts +2 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.esm.js +447 -31
- package/dist/page-snapshot/blocked-page.algorithm.d.ts +10 -0
- package/dist/page-snapshot/consent-banner.algorithm.d.ts +23 -0
- package/dist/page-snapshot/detect-url.use-case.d.ts +13 -0
- package/dist/page-snapshot/index.d.ts +2 -0
- package/dist/page-snapshot/inline-assets.mapper.d.ts +29 -0
- package/dist/page-snapshot/take-snapshot.use-case.d.ts +2 -0
- package/dist/recipe-workspace/import-document.use-case.d.ts +5 -3
- package/dist/src/document-view/pdf-view.mapper.d.ts +2 -0
- package/dist/src/index.d.ts +2 -2
- package/dist/src/page-snapshot/blocked-page.algorithm.d.ts +10 -0
- package/dist/src/page-snapshot/consent-banner.algorithm.d.ts +23 -0
- package/dist/src/page-snapshot/detect-url.use-case.d.ts +13 -0
- package/dist/src/page-snapshot/index.d.ts +2 -0
- package/dist/src/page-snapshot/inline-assets.mapper.d.ts +29 -0
- package/dist/src/page-snapshot/take-snapshot.use-case.d.ts +2 -0
- package/dist/src/recipe-workspace/import-document.use-case.d.ts +5 -3
- package/dist/src/studio-api/command.contract.d.ts +11 -0
- package/dist/src/studio-api/detect-url-view.contract.d.ts +24 -0
- package/dist/src/studio-api/index.d.ts +4 -2
- package/dist/src/studio-api/infer-selector-view.contract.d.ts +14 -0
- package/dist/src/studio-api/pdf-view.contract.d.ts +3 -0
- package/dist/src/studio-api/snapshot-view.contract.d.ts +1 -0
- package/dist/src/studio-server/detect-url.handler.d.ts +10 -0
- package/dist/studio-api/command.contract.d.ts +11 -0
- package/dist/studio-api/detect-url-view.contract.d.ts +24 -0
- package/dist/studio-api/index.d.ts +4 -2
- package/dist/studio-api/infer-selector-view.contract.d.ts +14 -0
- package/dist/studio-api/pdf-view.contract.d.ts +3 -0
- package/dist/studio-api/snapshot-view.contract.d.ts +1 -0
- package/dist/studio-server/detect-url.handler.d.ts +10 -0
- package/dist/ui/assets/index-DQ3QLedY.js +42 -0
- package/dist/ui/assets/{pdf-Dt4HqIG1.js → pdf-DJOnIZdZ.js} +1 -1
- package/dist/ui/index.html +11 -2
- package/dist/ui/opencraw-logo.svg +28 -0
- package/package.json +3 -3
- package/dist/ui/assets/index-DrJpOP0X.js +0 -38
package/dist/index.esm.js
CHANGED
|
@@ -3,7 +3,7 @@ import { createServer } from 'node:http';
|
|
|
3
3
|
import { promisify, stripVTControlCharacters } from 'node:util';
|
|
4
4
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
5
5
|
import { z } from 'zod';
|
|
6
|
-
import { BODY_KINDS, HttpClient, BrowserClient, NODE_ID_ATTRIBUTE, sessionStateOf, rowsOfCells, parseXml, findDeckTables, findGridTables, analyzeTables, parseRegion, regionText, deckRegionText, isDeckDocument, readRecipeSource, parseInputRecipe, parseOutputRecipe, calloutLabel, bindRecipeSet, memorySink, createCrawler, traceLine, UnknownHookError, resolveMissingPolicy, isWorkbookDocument, RecipeBindingError, RecipeValidationError, countMatches, matchesOf, tryParseJson, isPdfDocument, BrowserProfiles } from '@opencraw/core';
|
|
6
|
+
import { BODY_KINDS, formatOfResponse, HttpClient, BrowserClient, NODE_ID_ATTRIBUTE, sessionStateOf, rowsOfCells, parseXml, marginRows, findDeckTables, findGridTables, analyzeTables, parseRegion, regionText, deckRegionText, isDeckDocument, readRecipeSource, parseInputRecipe, parseOutputRecipe, calloutLabel, bindRecipeSet, memorySink, createCrawler, traceLine, UnknownHookError, resolveMissingPolicy, isWorkbookDocument, RecipeBindingError, RecipeValidationError, countMatches, matchesOf, tryParseJson, isPdfDocument, BrowserProfiles } from '@opencraw/core';
|
|
7
7
|
import { randomBytes, createHash } from 'node:crypto';
|
|
8
8
|
import { execFile } from 'node:child_process';
|
|
9
9
|
import { createRequire } from 'node:module';
|
|
@@ -52,6 +52,11 @@ const openWorkspaceCommandSchema = z.object({
|
|
|
52
52
|
type: z.literal('open-workspace'),
|
|
53
53
|
folder: z.string().min(1)
|
|
54
54
|
});
|
|
55
|
+
/** Says what a page address returns, so a recipe is started the right way: a JSON API is read with a request, a page with a browser. */
|
|
56
|
+
const detectUrlCommandSchema = z.object({
|
|
57
|
+
type: z.literal('detect-url'),
|
|
58
|
+
url: z.string().min(1)
|
|
59
|
+
});
|
|
55
60
|
/** Says what a path is (folder, recipe or document) and where to open it: how a link or an IDE entry opens a path (issue #235). `workspace` is the folder a document should belong to, when the caller has one. */
|
|
56
61
|
const openPathCommandSchema = z.object({
|
|
57
62
|
type: z.literal('open-path'),
|
|
@@ -134,7 +139,9 @@ const inferSelectorCommandSchema = z.object({
|
|
|
134
139
|
type: z.literal('infer-selector'),
|
|
135
140
|
recipeId: z.string().min(1),
|
|
136
141
|
path: z.string().min(1),
|
|
137
|
-
nodeIds: z.union([onePickedNodeSchema, twoPickedNodesSchema])
|
|
142
|
+
nodeIds: z.union([onePickedNodeSchema, twoPickedNodesSchema]),
|
|
143
|
+
/** The selector of the items of a list the recipe already loops over: a single pick inside one of them answers as a field of the item, to add to the loop. */
|
|
144
|
+
within: z.string().min(1).optional()
|
|
138
145
|
});
|
|
139
146
|
/**
|
|
140
147
|
* The Inspect panel's DOM tree and data-in-the-page findings, off the same
|
|
@@ -354,7 +361,7 @@ const importDocumentCommandSchema = z.object({
|
|
|
354
361
|
name: z.string().min(1),
|
|
355
362
|
bytes: z.string()
|
|
356
363
|
});
|
|
357
|
-
const studioCommandSchema = z.discriminatedUnion('type', [openWorkspaceCommandSchema, chooseFolderCommandSchema, openPathCommandSchema, importDocumentCommandSchema, runSampleCommandSchema, stopRunCommandSchema, saveRecipeCommandSchema, fetchStartPageCommandSchema, saveOutlineCommandSchema, takeSnapshotCommandSchema, verifySelectorCommandSchema, inferSelectorCommandSchema, explainWhyCommandSchema, inspectPageCommandSchema, responsesSeenCommandSchema, documentTreeCommandSchema, pdfViewCommandSchema, tablePreviewCommandSchema, regionPreviewCommandSchema, gridViewCommandSchema, gridPreviewCommandSchema, deckViewCommandSchema, deckPreviewCommandSchema, startRecordingCommandSchema, stopRecordingCommandSchema]);
|
|
364
|
+
const studioCommandSchema = z.discriminatedUnion('type', [openWorkspaceCommandSchema, chooseFolderCommandSchema, openPathCommandSchema, importDocumentCommandSchema, runSampleCommandSchema, stopRunCommandSchema, saveRecipeCommandSchema, fetchStartPageCommandSchema, detectUrlCommandSchema, saveOutlineCommandSchema, takeSnapshotCommandSchema, verifySelectorCommandSchema, inferSelectorCommandSchema, explainWhyCommandSchema, inspectPageCommandSchema, responsesSeenCommandSchema, documentTreeCommandSchema, pdfViewCommandSchema, tablePreviewCommandSchema, regionPreviewCommandSchema, gridViewCommandSchema, gridPreviewCommandSchema, deckViewCommandSchema, deckPreviewCommandSchema, startRecordingCommandSchema, stopRecordingCommandSchema]);
|
|
358
365
|
|
|
359
366
|
/**
|
|
360
367
|
* What `take-snapshot` answers with: the rewritten document ready for the
|
|
@@ -368,7 +375,9 @@ const snapshotViewSchema = z.object({
|
|
|
368
375
|
html: z.string(),
|
|
369
376
|
nodeCount: z.number(),
|
|
370
377
|
baseUrl: z.string(),
|
|
371
|
-
format: z.enum(BODY_KINDS).optional()
|
|
378
|
+
format: z.enum(BODY_KINDS).optional(),
|
|
379
|
+
/** Something to tell the person about the page: it is a bot check or an access-denied page, not the content. */
|
|
380
|
+
notice: z.string().optional()
|
|
372
381
|
});
|
|
373
382
|
|
|
374
383
|
/**
|
|
@@ -417,6 +426,9 @@ const inferSelectorViewSchema = z.discriminatedUnion('kind', [z.object({
|
|
|
417
426
|
kind: z.literal('list'),
|
|
418
427
|
item: itemPickSchema,
|
|
419
428
|
field: fieldPickSchema
|
|
429
|
+
}), z.object({
|
|
430
|
+
kind: z.literal('item-field'),
|
|
431
|
+
field: fieldPickSchema
|
|
420
432
|
}), z.object({
|
|
421
433
|
kind: z.literal('unsupported'),
|
|
422
434
|
reason: z.string()
|
|
@@ -585,6 +597,14 @@ const sampleRunViewSchema = z.object({
|
|
|
585
597
|
trace: z.array(z.string())
|
|
586
598
|
});
|
|
587
599
|
|
|
600
|
+
/**
|
|
601
|
+
* `detect-url`'s answer: what the address returns, so "Load ▾ Web page" starts the right recipe: `html` is a page (opened in a
|
|
602
|
+
* browser); `json`, `pdf`, `csv`, `xlsx`, `markdown`, `xml`... are documents (read with a request); `unknown` when it could not be fetched.
|
|
603
|
+
*/
|
|
604
|
+
const detectUrlViewSchema = z.object({
|
|
605
|
+
kind: z.enum(['html', 'json', 'jsonl', 'text', 'pdf', 'csv', 'xlsx', 'pptx', 'yaml', 'markdown', 'xml', 'docx', 'unknown'])
|
|
606
|
+
});
|
|
607
|
+
|
|
588
608
|
/** What `fetch-start-page` answers with. */
|
|
589
609
|
const startPageViewSchema = z.object({
|
|
590
610
|
html: z.string()
|
|
@@ -675,7 +695,9 @@ const pdfCellViewSchema = z.object({
|
|
|
675
695
|
const pdfRowViewSchema = z.object({
|
|
676
696
|
top: z.number(),
|
|
677
697
|
bottom: z.number(),
|
|
678
|
-
cells: z.array(pdfCellViewSchema)
|
|
698
|
+
cells: z.array(pdfCellViewSchema),
|
|
699
|
+
/** A running header or footer: the same text at the same height on several pages (page numbers aside). */
|
|
700
|
+
running: z.boolean().optional()
|
|
679
701
|
});
|
|
680
702
|
const pdfPageViewSchema = z.object({
|
|
681
703
|
number: z.number().int(),
|
|
@@ -1039,6 +1061,72 @@ async function handleChooseFolder(state) {
|
|
|
1039
1061
|
};
|
|
1040
1062
|
}
|
|
1041
1063
|
|
|
1064
|
+
/** How much of the start of a response is looked at: enough for the engine to tell a PDF or an Office package from its bytes, and for JSON to start. */
|
|
1065
|
+
const LOOKED_AT_BYTES = 65_536;
|
|
1066
|
+
/** How long to wait for an address to start answering. */
|
|
1067
|
+
const TIMEOUT_MS = 20_000;
|
|
1068
|
+
/**
|
|
1069
|
+
* What an address returns: the format the engine reads its response as (content type, then what the first bytes
|
|
1070
|
+
* show, then the file extension in the address), and, for an API that serves JSON as `text/plain` (common), JSON when
|
|
1071
|
+
* the text starts like an object or an array. Only the start of the response is read: a large file is not downloaded.
|
|
1072
|
+
* An address that cannot be fetched is `unknown`; the recipe started from it then reports why on its first snapshot.
|
|
1073
|
+
*
|
|
1074
|
+
* @param url - The address.
|
|
1075
|
+
* @returns The kind: `html` is a page to open in a browser, anything else a document to read.
|
|
1076
|
+
*/
|
|
1077
|
+
async function detectUrlKind(url) {
|
|
1078
|
+
const controller = new AbortController();
|
|
1079
|
+
const timer = setTimeout(() => {
|
|
1080
|
+
controller.abort();
|
|
1081
|
+
}, TIMEOUT_MS);
|
|
1082
|
+
try {
|
|
1083
|
+
const response = await fetch(url, {
|
|
1084
|
+
signal: controller.signal,
|
|
1085
|
+
headers: {
|
|
1086
|
+
'user-agent': 'Mozilla/5.0 (compatible; OpenCrawStudio)',
|
|
1087
|
+
'accept': '*/*'
|
|
1088
|
+
}
|
|
1089
|
+
});
|
|
1090
|
+
const bytes = await firstBytes(response);
|
|
1091
|
+
const named = downloadName(response.headers.get('content-disposition'));
|
|
1092
|
+
const where = named === undefined ? response.url === '' ? url : response.url : `https://download.invalid/${named}`;
|
|
1093
|
+
const format = formatOfResponse(response.headers.get('content-type') ?? '', where, bytes, response.ok);
|
|
1094
|
+
if (format === 'text' && /^[[{]/.test(new TextDecoder().decode(bytes).trimStart())) return 'json';
|
|
1095
|
+
return format;
|
|
1096
|
+
} catch {
|
|
1097
|
+
return 'unknown';
|
|
1098
|
+
} finally {
|
|
1099
|
+
clearTimeout(timer);
|
|
1100
|
+
controller.abort();
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
/** The first bytes of a response body; the rest is never downloaded. */
|
|
1104
|
+
async function firstBytes(response) {
|
|
1105
|
+
const reader = response.body?.getReader();
|
|
1106
|
+
if (reader === undefined) return new Uint8Array();
|
|
1107
|
+
const chunks = [];
|
|
1108
|
+
let length = 0;
|
|
1109
|
+
while (length < LOOKED_AT_BYTES) {
|
|
1110
|
+
const {
|
|
1111
|
+
done,
|
|
1112
|
+
value
|
|
1113
|
+
} = await reader.read();
|
|
1114
|
+
if (done) break;
|
|
1115
|
+
chunks.push(value);
|
|
1116
|
+
length += value.length;
|
|
1117
|
+
}
|
|
1118
|
+
try {
|
|
1119
|
+
await reader.cancel();
|
|
1120
|
+
} catch {
|
|
1121
|
+
// The rest of the body was not wanted anyway.
|
|
1122
|
+
}
|
|
1123
|
+
return Buffer.concat(chunks);
|
|
1124
|
+
}
|
|
1125
|
+
/** The file name a download is offered under (`Content-Disposition: attachment; filename=series.csv`): an address like `/generator?format=csv` says nothing about it. */
|
|
1126
|
+
function downloadName(disposition) {
|
|
1127
|
+
return disposition === null ? undefined : /filename=(?:UTF-8'')?"?([^";]+)"?/i.exec(disposition)?.[1]?.trim();
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1042
1130
|
const execFileAsync = promisify(execFile);
|
|
1043
1131
|
/** `playwright`'s own `exports` map doesn't whitelist a `./cli` subpath, so its CLI is resolved off `playwright/package.json`'s real location — the same technique `@opencraw/captcha-tesseract`'s `bundledLangPath()` uses for a bundled data path. */
|
|
1044
1132
|
function playwrightCliPath() {
|
|
@@ -1198,6 +1286,102 @@ function textOf$2(body) {
|
|
|
1198
1286
|
return `[${body.kind} document: the content pane shows HTML and JSON responses for now]`;
|
|
1199
1287
|
}
|
|
1200
1288
|
|
|
1289
|
+
/** What a bot-protection or access-denied page says about itself, in the words the common services use. */
|
|
1290
|
+
const BLOCKED_PATTERNS = [/performing security verification/i, /verify(?:ing)? (?:that )?you are (?:a )?human/i, /are you a robot/i, /checking your browser/i, /enable javascript and cookies to continue/i, /just a moment\.\.\./i, /attention required/i, /access denied/i, /request (?:was )?blocked/i, /unusual traffic/i, /pardon our interruption/i];
|
|
1291
|
+
/**
|
|
1292
|
+
* Whether a page is the site's bot check or an access-denied page instead of its content, and, if so, what to tell the person: the
|
|
1293
|
+
* Studio's browser was turned away, so everything else on the screen is not the page they asked for.
|
|
1294
|
+
*
|
|
1295
|
+
* @param text - The page's title and the start of its visible text.
|
|
1296
|
+
* @param status - The HTTP status the page came with, when known.
|
|
1297
|
+
* @returns A sentence for the person, or `undefined` when the page looks like a page.
|
|
1298
|
+
*/
|
|
1299
|
+
function blockedPageNotice(text, status) {
|
|
1300
|
+
const turnedAway = status === 403 || status === 429;
|
|
1301
|
+
if (!turnedAway && BLOCKED_PATTERNS.every(pattern => !pattern.test(text))) return undefined;
|
|
1302
|
+
if (turnedAway && text.trim().length > 4000) return undefined;
|
|
1303
|
+
return 'This site is showing a security check or an access-denied page instead of its content: it has turned the Studio\'s browser away. Try again later, or look for the data the site itself offers (a download, a feed or an API).';
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
/** The attribute a pop-up that covers the page is marked with in the snapshot; the picture leaves it out unless "Show hidden" is on. */
|
|
1307
|
+
/**
|
|
1308
|
+
* A script that, in a page, finds a cookie or consent banner's button and presses it: "Reject all", "Only necessary cookies" and the
|
|
1309
|
+
* like first, else "Accept all", "I agree"... in English, Portuguese, Spanish, French, Italian, German and Dutch, and by the ids the common
|
|
1310
|
+
* consent tools give their buttons (OneTrust, Didomi, Cookiebot, Usercentrics, Osano, iubenda). A banner that is left in place covers the
|
|
1311
|
+
* page in the Studio's view of it, and the Studio's own clicks (to pick a value) would land on it. Only buttons whose whole text is one
|
|
1312
|
+
* of these are pressed, so a "Cookie settings" or "Gerir os cookies" button, a link to a policy and the page's own buttons are left alone.
|
|
1313
|
+
* The answer is the text of the button pressed, or an empty string when there was none.
|
|
1314
|
+
*
|
|
1315
|
+
* It only ever runs in the browser the Studio captures the page with, never in a crawl.
|
|
1316
|
+
*/
|
|
1317
|
+
const DISMISS_CONSENT_SCRIPT = String.raw`(() => {
|
|
1318
|
+
const rejectIds = ['#onetrust-reject-all-handler', '#didomi-notice-disagree-button', '#CybotCookiebotDialogBodyButtonDecline', '[data-testid=uc-deny-all-button]', '.osano-cm-denyAll', '.iubenda-cs-reject-btn', '.cmp-button_button--reject', '#truste-consent-required', 'button[id*=reject i]', 'button[id*=decline i]']
|
|
1319
|
+
const acceptIds = ['#onetrust-accept-btn-handler', '#didomi-notice-agree-button', '#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll', '[data-testid=uc-accept-all-button]', '.osano-cm-accept-all', '.iubenda-cs-accept-btn', 'button[id*=accept i]']
|
|
1320
|
+
const rejectWords = ['reject', 'decline', 'deny', 'refuse', 'rejeitar', 'recusar', 'nao aceitar', 'rechazar', 'refuser', 'rifiuta', 'rifiutare', 'ablehnen', 'weigeren', 'only necessary', 'only essential', 'necessary only', 'essential only', 'apenas necessarios', 'apenas essenciais', 'somente necessarios', 'solo necesarias', 'solo necessari', 'uniquement necessaires', 'uniquement essentiels', 'nur notwendige', 'nur erforderliche', 'alleen noodzakelijke', 'continue without accepting', 'continuar sem aceitar', 'continuar sin aceptar', 'continuer sans accepter', 'continua senza accettare', 'no thanks', 'nao obrigado']
|
|
1321
|
+
const acceptWords = ['accept', 'allow', 'agree', 'i agree', 'i accept', 'ok', 'okay', 'got it', 'yes i agree', 'yes these cookies are ok', 'aceitar', 'aceito', 'concordo', 'permitir', 'aceptar', 'acepto', 'permitir todas', 'accepter', 'j accepte', 'accetta', 'accetto', 'akzeptieren', 'zustimmen', 'einverstanden', 'accepteren']
|
|
1322
|
+
const tail = '(?:\\s+(?:all|todos|todas|todo|tudo|tout|tous|tutti|tutte|alle|alles|os|as|las|los|les|i|le|the|die))*(?:\\s+(?:cookies?|kekse|koekjes))?'
|
|
1323
|
+
const rejectText = new RegExp('^(?:' + rejectWords.join('|') + ')' + tail + '$')
|
|
1324
|
+
const acceptText = new RegExp('^(?:' + acceptWords.join('|') + ')' + tail + '$')
|
|
1325
|
+
const visible = (element) => {
|
|
1326
|
+
const box = element.getBoundingClientRect()
|
|
1327
|
+
const style = getComputedStyle(element)
|
|
1328
|
+
|
|
1329
|
+
return box.width > 0 && box.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'
|
|
1330
|
+
}
|
|
1331
|
+
const plain = (text) => text.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-z\s]/g, ' ').replace(/\s+/g, ' ').trim()
|
|
1332
|
+
const label = (element) => (element.innerText || element.value || element.getAttribute('aria-label') || '').trim().replace(/\s+/g, ' ')
|
|
1333
|
+
const byId = (selectors) => {
|
|
1334
|
+
for (const selector of selectors) {
|
|
1335
|
+
const found = [...document.querySelectorAll(selector)].find(visible)
|
|
1336
|
+
if (found !== undefined) return found
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
return undefined
|
|
1340
|
+
}
|
|
1341
|
+
const candidates = [...document.querySelectorAll('button, a, [role=button], input[type=button], input[type=submit]')].filter(visible)
|
|
1342
|
+
const byText = (pattern) => candidates.find((element) => pattern.test(plain(label(element))))
|
|
1343
|
+
const target = byId(rejectIds) ?? byText(rejectText) ?? byId(acceptIds) ?? byText(acceptText)
|
|
1344
|
+
if (target === undefined) return ''
|
|
1345
|
+
const text = label(target)
|
|
1346
|
+
target.click()
|
|
1347
|
+
|
|
1348
|
+
return text || target.id
|
|
1349
|
+
})()`;
|
|
1350
|
+
/**
|
|
1351
|
+
* A script that marks, in the page, every pop-up that covers it: something fixed to the window that fills most of it (a dimming backdrop,
|
|
1352
|
+
* a modal), a modal dialog, or a banner a consent tool put there. The picture of the page leaves them out (the page behind is what the
|
|
1353
|
+
* person wants to pick from), and "Show hidden" brings them back. Run after the consent button has been tried: a banner that went away
|
|
1354
|
+
* needs nothing, one that stayed (its button in a form the script does not know, an icon with no text) is covered here.
|
|
1355
|
+
*
|
|
1356
|
+
* Only the outermost element of an overlay is marked, since hiding it hides all it holds.
|
|
1357
|
+
*/
|
|
1358
|
+
const MARK_OVERLAYS_SCRIPT = `(() => {
|
|
1359
|
+
const attribute = 'data-oc-overlay'
|
|
1360
|
+
const consentName = /cookie|consent|gdpr|privacy|onetrust|ot-sdk|didomi|cookiebot|usercentrics|trustarc|truste|osano|iubenda|cmp|banner-modal/i
|
|
1361
|
+
const width = window.innerWidth
|
|
1362
|
+
const height = window.innerHeight
|
|
1363
|
+
const marked = []
|
|
1364
|
+
for (const element of document.querySelectorAll('body *')) {
|
|
1365
|
+
if (marked.some((outer) => outer.contains(element))) continue
|
|
1366
|
+
const style = getComputedStyle(element)
|
|
1367
|
+
if (style.position !== 'fixed' || style.display === 'none' || style.visibility === 'hidden') continue
|
|
1368
|
+
const box = element.getBoundingClientRect()
|
|
1369
|
+
if (box.width === 0 || box.height === 0) continue
|
|
1370
|
+
const covers = box.width >= width * 0.6 && box.height >= height * 0.5
|
|
1371
|
+
const modal = element.getAttribute('aria-modal') === 'true' || element.getAttribute('role') === 'dialog' || element.getAttribute('role') === 'alertdialog'
|
|
1372
|
+
const named = consentName.test((element.id || '') + ' ' + (typeof element.className === 'string' ? element.className : ''))
|
|
1373
|
+
// A page built as one fixed, scrolling frame is the page, not a pop-up on it.
|
|
1374
|
+
if (covers && !modal && !named && (element.innerText || '').length > document.body.innerText.length * 0.6) continue
|
|
1375
|
+
// A bar along an edge (a site's own header or footer) is not a pop-up unless a consent tool named it.
|
|
1376
|
+
if (covers || modal || named) {
|
|
1377
|
+
element.setAttribute(attribute, '1')
|
|
1378
|
+
marked.push(element)
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
return marked.length
|
|
1383
|
+
})()`;
|
|
1384
|
+
|
|
1201
1385
|
/** The attribute `hiddenMarksScript` stamps on an element hidden on the live page. */
|
|
1202
1386
|
const HIDDEN_ATTRIBUTE = 'data-oc-hidden';
|
|
1203
1387
|
/**
|
|
@@ -1233,6 +1417,79 @@ function hiddenMarksScript(attribute = HIDDEN_ATTRIBUTE) {
|
|
|
1233
1417
|
})()`;
|
|
1234
1418
|
}
|
|
1235
1419
|
|
|
1420
|
+
const FONT_TYPES = {
|
|
1421
|
+
woff2: 'font/woff2',
|
|
1422
|
+
woff: 'font/woff',
|
|
1423
|
+
ttf: 'font/ttf',
|
|
1424
|
+
otf: 'font/otf',
|
|
1425
|
+
eot: 'application/vnd.ms-fontobject',
|
|
1426
|
+
svg: 'image/svg+xml'
|
|
1427
|
+
};
|
|
1428
|
+
/** `url(...)` in a stylesheet, quoted or not. */
|
|
1429
|
+
const CSS_URL = /url\(\s*(['"]?)([^'"\s)]+)\1\s*\)/g;
|
|
1430
|
+
/** The address a `url()` in a stylesheet at `base` points to, or `undefined` for one that is not an address (inline data, a fragment). */
|
|
1431
|
+
function resolved(reference, base) {
|
|
1432
|
+
if (reference.startsWith('data:') || reference.startsWith('#')) return undefined;
|
|
1433
|
+
try {
|
|
1434
|
+
return new URL(reference, base).href;
|
|
1435
|
+
} catch {
|
|
1436
|
+
return undefined;
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
function fontType(address) {
|
|
1440
|
+
const extension = /\.([a-z0-9]+)(?:[?#]|$)/i.exec(new URL(address).pathname + new URL(address).search)?.[1]?.toLowerCase();
|
|
1441
|
+
return (extension === undefined ? undefined : FONT_TYPES[extension]) ?? 'application/octet-stream';
|
|
1442
|
+
}
|
|
1443
|
+
/**
|
|
1444
|
+
* A stylesheet's text with every `url(...)` made absolute, and each font the page loaded embedded as a data address. The picture of a page
|
|
1445
|
+
* is shown in a frame that is not the site, so a font file is a cross-site request the site's server does not allow: its text comes
|
|
1446
|
+
* out in a fallback face and its icons (a menu, a close button) as empty boxes. A font inside the page cannot be refused.
|
|
1447
|
+
*
|
|
1448
|
+
* @param css - The stylesheet text.
|
|
1449
|
+
* @param base - Where the stylesheet came from (or the page, for a `<style>` in it).
|
|
1450
|
+
* @param assets - What the page loaded, by address.
|
|
1451
|
+
* @returns The stylesheet, ready to sit inside the snapshot.
|
|
1452
|
+
*/
|
|
1453
|
+
function embedFonts(css, base, assets) {
|
|
1454
|
+
// eslint-disable-next-line unicorn/prefer-string-replace-all -- one replacer with a global pattern
|
|
1455
|
+
return css.replace(CSS_URL, (whole, _quote, reference) => {
|
|
1456
|
+
const address = resolved(reference.trim(), base);
|
|
1457
|
+
if (address === undefined) return whole;
|
|
1458
|
+
const asset = assets.get(address);
|
|
1459
|
+
if (asset?.kind !== 'font') return `url("${address}")`;
|
|
1460
|
+
return `url("data:${fontType(address)};base64,${Buffer.from(asset.body).toString('base64')}")`;
|
|
1461
|
+
});
|
|
1462
|
+
}
|
|
1463
|
+
/**
|
|
1464
|
+
* Turns a captured page into one that carries its own styles: every `<link rel="stylesheet">` the page loaded becomes a `<style>` with
|
|
1465
|
+
* the stylesheet's text, and the fonts in it (and in the page's own `<style>`s) are embedded. A stylesheet that was not captured is left
|
|
1466
|
+
* as the link it was.
|
|
1467
|
+
*
|
|
1468
|
+
* @param html - The page as captured.
|
|
1469
|
+
* @param pageUrl - The page's address.
|
|
1470
|
+
* @param assets - The stylesheets and fonts the page loaded, by address.
|
|
1471
|
+
* @returns The page with its styles and fonts inside it.
|
|
1472
|
+
*/
|
|
1473
|
+
function inlineAssets(html, pageUrl, assets) {
|
|
1474
|
+
const $ = load(html);
|
|
1475
|
+
$('link[rel~="stylesheet"][href]').each((_index, element) => {
|
|
1476
|
+
const reference = $(element).attr('href') ?? '';
|
|
1477
|
+
const address = resolved(reference, pageUrl);
|
|
1478
|
+
const asset = address === undefined ? undefined : assets.get(address);
|
|
1479
|
+
if (address === undefined || asset?.kind !== 'css') return;
|
|
1480
|
+
const css = embedFonts(Buffer.from(asset.body).toString('utf8'), address, assets);
|
|
1481
|
+
const inlined = $('<style></style>').attr('data-oc-from', address);
|
|
1482
|
+
inlined.text(css);
|
|
1483
|
+
$(element).replaceWith(inlined);
|
|
1484
|
+
});
|
|
1485
|
+
$('style').each((_index, element) => {
|
|
1486
|
+
if ($(element).attr('data-oc-from') !== undefined) return;
|
|
1487
|
+
const css = $(element).text();
|
|
1488
|
+
if (css.includes('url(')) $(element).text(embedFonts(css, pageUrl, assets));
|
|
1489
|
+
});
|
|
1490
|
+
return $.html();
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1236
1493
|
/** Attributes that carry a URL and are rewritten to absolute; `srcset` is handled separately (it carries several). */
|
|
1237
1494
|
const URL_ATTRIBUTES = ['href', 'src', 'action', 'poster'];
|
|
1238
1495
|
/** Schemes left untouched: they are not a location `new URL(value, base)` should resolve, or already carry their own meaning. */
|
|
@@ -1312,6 +1569,11 @@ function escapeAttribute(value) {
|
|
|
1312
1569
|
return value.replaceAll('&', '&').replaceAll('"', '"');
|
|
1313
1570
|
}
|
|
1314
1571
|
|
|
1572
|
+
/** How long a cookie banner is given to go away once its button is pressed. */
|
|
1573
|
+
const CONSENT_SETTLE_MS = 1200;
|
|
1574
|
+
/** A banner appears a moment after the page loads (some only after its scripts have run for a few seconds): it is looked for this many times, this far apart, and a page with none costs this much time. */
|
|
1575
|
+
const CONSENT_ATTEMPTS = 6;
|
|
1576
|
+
const CONSENT_RETRY_MS = 800;
|
|
1315
1577
|
/**
|
|
1316
1578
|
* Captures the snapshot the content pane shows (studio plan §3.1, issue
|
|
1317
1579
|
* #91): runs the recipe up to `stepPath`, serialises the document, marks
|
|
@@ -1341,7 +1603,7 @@ async function takeSnapshot(input, stepPath, browser, hooks) {
|
|
|
1341
1603
|
const point = input.start[0];
|
|
1342
1604
|
if (point === undefined) throw new Error(`recipe "${input.id}" has no start point`);
|
|
1343
1605
|
const storageState = await loginState(input, browser, hooks);
|
|
1344
|
-
return input.mode === 'web' ? snapshotWeb(point.url, storageState, browser) : snapshotApi(point.url, storageState);
|
|
1606
|
+
return input.mode === 'web' ? snapshotWeb(point.url, storageState, browser) : snapshotApi(point.url, storageState, requestedAs(input));
|
|
1345
1607
|
}
|
|
1346
1608
|
/** What the recipe's own login leaves behind, so the snapshot shows the page as the crawl sees it (issue #185); nothing when it declares no login. */
|
|
1347
1609
|
async function loginState(input, browser, hooks) {
|
|
@@ -1366,15 +1628,34 @@ async function snapshotWeb(url, storageState, browser) {
|
|
|
1366
1628
|
storageState
|
|
1367
1629
|
});
|
|
1368
1630
|
try {
|
|
1369
|
-
|
|
1631
|
+
const assets = new Map();
|
|
1632
|
+
const captures = [];
|
|
1633
|
+
const budget = {
|
|
1634
|
+
bytes: 0
|
|
1635
|
+
};
|
|
1636
|
+
session.page.on('response', loaded => {
|
|
1637
|
+
captures.push(captureAsset(loaded, assets, budget));
|
|
1638
|
+
});
|
|
1639
|
+
const response = await session.page.goto(url);
|
|
1640
|
+
// A cookie banner left in place covers the page in this view, and the clicks that pick a value would land on it.
|
|
1641
|
+
await dismissConsent(session.page);
|
|
1642
|
+
// What still covers the page after that (a modal whose button the script does not know) is left out of the picture: "Show hidden" brings it back.
|
|
1643
|
+
await session.page.evaluate(MARK_OVERLAYS_SCRIPT);
|
|
1644
|
+
const seen = String(await session.page.evaluate(String.raw`document.title + "\n" + (document.body ? document.body.innerText : "").slice(0, 3000)`));
|
|
1370
1645
|
await session.page.evaluate(hiddenMarksScript(HIDDEN_ATTRIBUTE));
|
|
1371
|
-
|
|
1646
|
+
await Promise.all(captures);
|
|
1372
1647
|
const baseUrl = session.page.url();
|
|
1648
|
+
// The page's own styles and fonts go inside the picture: the frame that shows it is not the site, so the site's fonts would be refused there.
|
|
1649
|
+
const html = inlineAssets(await session.page.content(), baseUrl, assets);
|
|
1373
1650
|
const rewritten = rewriteDocument(html, baseUrl);
|
|
1651
|
+
const notice = blockedPageNotice(seen, response?.status());
|
|
1374
1652
|
return {
|
|
1375
1653
|
...rewritten,
|
|
1376
1654
|
baseUrl,
|
|
1377
|
-
rawHtml: html
|
|
1655
|
+
rawHtml: html,
|
|
1656
|
+
...(notice !== undefined && {
|
|
1657
|
+
notice
|
|
1658
|
+
})
|
|
1378
1659
|
};
|
|
1379
1660
|
} finally {
|
|
1380
1661
|
await session.close();
|
|
@@ -1383,13 +1664,63 @@ async function snapshotWeb(url, storageState, browser) {
|
|
|
1383
1664
|
await client.close();
|
|
1384
1665
|
}
|
|
1385
1666
|
}
|
|
1386
|
-
|
|
1667
|
+
/** The most a stylesheet may weigh to be kept, the most a font may, and how much of both a page may bring (bytes): a picture is not worth a heavier page than this. */
|
|
1668
|
+
const MAX_CSS_BYTES = 800_000;
|
|
1669
|
+
const MAX_FONT_BYTES = 600_000;
|
|
1670
|
+
const MAX_ASSET_BUDGET = 6_000_000;
|
|
1671
|
+
/** Keeps a stylesheet or a font the page loaded, within the limits above; anything else, or anything that cannot be read, is let go. */
|
|
1672
|
+
async function captureAsset(loaded, assets, budget) {
|
|
1673
|
+
const type = loaded.request().resourceType();
|
|
1674
|
+
if (type !== 'stylesheet' && type !== 'font') return;
|
|
1675
|
+
try {
|
|
1676
|
+
const body = await loaded.body();
|
|
1677
|
+
if (body.length > (type === 'font' ? MAX_FONT_BYTES : MAX_CSS_BYTES) || budget.bytes + body.length > MAX_ASSET_BUDGET) return;
|
|
1678
|
+
budget.bytes += body.length;
|
|
1679
|
+
assets.set(loaded.url(), {
|
|
1680
|
+
kind: type === 'font' ? 'font' : 'css',
|
|
1681
|
+
body
|
|
1682
|
+
});
|
|
1683
|
+
} catch {
|
|
1684
|
+
// A response that went away before it could be read is not kept.
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
/** How the recipe's own first request reads the document (`as`), so a JSON API served as `text/plain` is shown as JSON here too. */
|
|
1688
|
+
function requestedAs(input) {
|
|
1689
|
+
const request = input.steps.find(step => step.type === 'request');
|
|
1690
|
+
return request?.type === 'request' ? request.as : undefined;
|
|
1691
|
+
}
|
|
1692
|
+
/**
|
|
1693
|
+
* Presses the cookie banner's reject (or accept) button, if the page shows one. Many sites put their consent dialog in a frame of its
|
|
1694
|
+
* own (a separate document, often covering the whole window), so every frame is searched, not only the page. The page is looked at
|
|
1695
|
+
* a few times, since banners come late.
|
|
1696
|
+
*/
|
|
1697
|
+
async function dismissConsent(page) {
|
|
1698
|
+
for (let attempt = 0; attempt < CONSENT_ATTEMPTS; attempt += 1) {
|
|
1699
|
+
for (const frame of page.frames()) {
|
|
1700
|
+
let pressed = '';
|
|
1701
|
+
try {
|
|
1702
|
+
pressed = String(await frame.evaluate(DISMISS_CONSENT_SCRIPT));
|
|
1703
|
+
} catch {
|
|
1704
|
+
// A frame that went away or is not ready has no banner to press.
|
|
1705
|
+
}
|
|
1706
|
+
if (pressed !== '') {
|
|
1707
|
+
await page.waitForTimeout(CONSENT_SETTLE_MS);
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
await page.waitForTimeout(CONSENT_RETRY_MS);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
async function snapshotApi(url, storageState, as) {
|
|
1387
1715
|
const client = await HttpClient.open({
|
|
1388
1716
|
storageState
|
|
1389
1717
|
});
|
|
1390
1718
|
try {
|
|
1391
1719
|
const response = await client.send({
|
|
1392
|
-
url
|
|
1720
|
+
url,
|
|
1721
|
+
...(as !== undefined && {
|
|
1722
|
+
as
|
|
1723
|
+
})
|
|
1393
1724
|
});
|
|
1394
1725
|
const text = textOf$1(response.body);
|
|
1395
1726
|
const rewritten = rewriteDocument(text, url);
|
|
@@ -1918,6 +2249,7 @@ function textOf(element) {
|
|
|
1918
2249
|
* @returns The view.
|
|
1919
2250
|
*/
|
|
1920
2251
|
function pdfDocumentView(document) {
|
|
2252
|
+
const margins = marginRows(document);
|
|
1921
2253
|
return {
|
|
1922
2254
|
pages: document.pages.map(page => ({
|
|
1923
2255
|
number: page.number,
|
|
@@ -1932,7 +2264,10 @@ function pdfDocumentView(document) {
|
|
|
1932
2264
|
width: cell.width,
|
|
1933
2265
|
height: cell.height,
|
|
1934
2266
|
text: cell.text
|
|
1935
|
-
}))
|
|
2267
|
+
})),
|
|
2268
|
+
...(margins.has(row) && {
|
|
2269
|
+
running: true
|
|
2270
|
+
})
|
|
1936
2271
|
})),
|
|
1937
2272
|
rowCount: page.rows.length,
|
|
1938
2273
|
cellCount: page.rows.reduce((total, row) => total + row.cells.length, 0),
|
|
@@ -3053,7 +3388,7 @@ function nodeToStep(node) {
|
|
|
3053
3388
|
*/
|
|
3054
3389
|
async function openWorkspace(folder) {
|
|
3055
3390
|
const documents = await readRecipeSource(folder);
|
|
3056
|
-
const entries = documents.map(document => parseDocument$1(document));
|
|
3391
|
+
const entries = await Promise.all(documents.map(async document => parseDocument$1(document, await storedContent(document))));
|
|
3057
3392
|
const outputs = entries.filter(isParsed('output'));
|
|
3058
3393
|
const inputs = entries.filter(isParsed('input'));
|
|
3059
3394
|
for (const output of outputs) bindOutput(output, inputs);
|
|
@@ -3073,16 +3408,16 @@ async function openWorkspace(folder) {
|
|
|
3073
3408
|
kind,
|
|
3074
3409
|
id,
|
|
3075
3410
|
issues,
|
|
3076
|
-
|
|
3411
|
+
stored
|
|
3077
3412
|
}) => ({
|
|
3078
3413
|
file,
|
|
3079
3414
|
kind,
|
|
3080
3415
|
id,
|
|
3081
3416
|
issues,
|
|
3082
|
-
text: `${JSON.stringify(
|
|
3417
|
+
text: `${JSON.stringify(stored, null, 2)}\n`,
|
|
3083
3418
|
// The Steps tab needs steps to render: an output/unknown file has none. recipeToOutline never
|
|
3084
3419
|
// throws, so this covers a file that fails validation too (its outline just has "custom" cards).
|
|
3085
|
-
outline: kind === 'input' ? recipeToOutline(
|
|
3420
|
+
outline: kind === 'input' ? recipeToOutline(stored) : undefined
|
|
3086
3421
|
}))
|
|
3087
3422
|
};
|
|
3088
3423
|
}
|
|
@@ -3106,14 +3441,24 @@ function bindOutput(output, inputs) {
|
|
|
3106
3441
|
function isParsed(kind) {
|
|
3107
3442
|
return entry => entry.kind === kind && entry.recipe !== undefined;
|
|
3108
3443
|
}
|
|
3109
|
-
|
|
3444
|
+
/** The recipe as its `.json` file holds it; the decoded content for anything else (a JSON Lines line, text, an object), or a file that cannot be read back. */
|
|
3445
|
+
async function storedContent(document) {
|
|
3446
|
+
if (!document.source.endsWith('.json')) return document.content;
|
|
3447
|
+
try {
|
|
3448
|
+
return JSON.parse(await readFile(document.source, 'utf8'));
|
|
3449
|
+
} catch {
|
|
3450
|
+
return document.content;
|
|
3451
|
+
}
|
|
3452
|
+
}
|
|
3453
|
+
function parseDocument$1(document, stored) {
|
|
3110
3454
|
const kind = document.content?.kind;
|
|
3111
|
-
if (kind === 'output') return parseOne(document.source, document.content, 'output', parseOutputRecipe);
|
|
3112
|
-
if (kind === 'input') return parseOne(document.source, document.content, 'input', parseInputRecipe);
|
|
3455
|
+
if (kind === 'output') return parseOne(document.source, document.content, stored, 'output', parseOutputRecipe);
|
|
3456
|
+
if (kind === 'input') return parseOne(document.source, document.content, stored, 'input', parseInputRecipe);
|
|
3113
3457
|
return {
|
|
3114
3458
|
file: document.source,
|
|
3115
3459
|
kind: 'unknown',
|
|
3116
3460
|
content: document.content,
|
|
3461
|
+
stored,
|
|
3117
3462
|
issues: [{
|
|
3118
3463
|
path: 'kind',
|
|
3119
3464
|
message: '"kind" is missing or not "input"/"output"',
|
|
@@ -3121,13 +3466,14 @@ function parseDocument$1(document) {
|
|
|
3121
3466
|
}]
|
|
3122
3467
|
};
|
|
3123
3468
|
}
|
|
3124
|
-
function parseOne(file, content, kind, parse) {
|
|
3469
|
+
function parseOne(file, content, stored, kind, parse) {
|
|
3125
3470
|
try {
|
|
3126
3471
|
const recipe = parse(content, file);
|
|
3127
3472
|
return {
|
|
3128
3473
|
file,
|
|
3129
3474
|
kind,
|
|
3130
3475
|
content,
|
|
3476
|
+
stored,
|
|
3131
3477
|
id: recipe.id,
|
|
3132
3478
|
issues: [],
|
|
3133
3479
|
recipe
|
|
@@ -3138,6 +3484,7 @@ function parseOne(file, content, kind, parse) {
|
|
|
3138
3484
|
file,
|
|
3139
3485
|
kind,
|
|
3140
3486
|
content,
|
|
3487
|
+
stored,
|
|
3141
3488
|
issues: error.issues.map(issue => ({
|
|
3142
3489
|
path: issue.path === '' ? '(root)' : issue.path,
|
|
3143
3490
|
message: issue.message,
|
|
@@ -3201,8 +3548,10 @@ const MAX_NUMBERED_COPIES = 100;
|
|
|
3201
3548
|
* `file:` URL the same way a run would read any downloaded file (core's
|
|
3202
3549
|
* `HttpClient` handles `file:` itself). The folder is created when missing,
|
|
3203
3550
|
* so dropping a file is also a way to start a workspace. A name already in
|
|
3204
|
-
* the folder is never overwritten:
|
|
3205
|
-
*
|
|
3551
|
+
* the folder is never overwritten: a file there with the very same bytes is
|
|
3552
|
+
* reused (loading one document twice leaves one copy), a different one gets
|
|
3553
|
+
* the next free numbered name (`report-2.pdf`); the returned `path`/`url`
|
|
3554
|
+
* say which.
|
|
3206
3555
|
*
|
|
3207
3556
|
* @param folder - The workspace folder; created (recursively) when it does not exist.
|
|
3208
3557
|
* @param name - The file's own name, no directories: the copy keeps it, extension included.
|
|
@@ -3223,10 +3572,14 @@ async function importDocument(folder, name, bytes) {
|
|
|
3223
3572
|
});
|
|
3224
3573
|
return {
|
|
3225
3574
|
path,
|
|
3226
|
-
url:
|
|
3575
|
+
url: `file:${encodeURIComponent(candidate)}`
|
|
3227
3576
|
};
|
|
3228
3577
|
} catch (error) {
|
|
3229
3578
|
if (!isAlreadyThere(error)) throw error;
|
|
3579
|
+
if (await hasBytes(path, bytes)) return {
|
|
3580
|
+
path,
|
|
3581
|
+
url: `file:${encodeURIComponent(candidate)}`
|
|
3582
|
+
};
|
|
3230
3583
|
}
|
|
3231
3584
|
}
|
|
3232
3585
|
throw new Error(`"${name}" and ${MAX_NUMBERED_COPIES} numbered copies of it are already in ${folder}`);
|
|
@@ -3242,6 +3595,11 @@ function* candidateNames(name) {
|
|
|
3242
3595
|
const stem = name.slice(0, name.length - extension.length);
|
|
3243
3596
|
for (let copy = 2; copy <= MAX_NUMBERED_COPIES + 1; copy += 1) yield `${stem}-${copy}${extension}`;
|
|
3244
3597
|
}
|
|
3598
|
+
/** Whether the file at `path` is, byte for byte, `bytes`. */
|
|
3599
|
+
async function hasBytes(path, bytes) {
|
|
3600
|
+
const existing = await readFile(path);
|
|
3601
|
+
return existing.length === bytes.length && existing.equals(bytes);
|
|
3602
|
+
}
|
|
3245
3603
|
function isAlreadyThere(error) {
|
|
3246
3604
|
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST';
|
|
3247
3605
|
}
|
|
@@ -3495,7 +3853,10 @@ const GENERATED_TOKEN_PATTERNS = [/** styled-components / emotion: `sc-bdVaJa`,
|
|
|
3495
3853
|
/^(?:jss|mui[a-z]*-)[a-z0-9-]*\d{2}$/i, /** A token that is only digits: not a class a human would type. */
|
|
3496
3854
|
/^\d+$/, /** A bare hex hash: `a3f9c21`, `1a2b3c4d` (6+ hex chars, nothing else). */
|
|
3497
3855
|
/^[0-9a-f]{6,}$/i, /** Tailwind/atomic-CSS-in-JS runtime classes some bundlers emit: `_1a2b3c` */
|
|
3498
|
-
/^_[0-9a-z]{5,}$/i
|
|
3856
|
+
/^_[0-9a-z]{5,}$/i, /** A framework's runtime state, added and removed as the page runs, not written by anyone: AngularJS/Angular `ng-scope`, `ng-binding`, `ng-star-inserted`, `ng-valid`… */
|
|
3857
|
+
/^ng-/, /** Ember's: `ember-view`, `ember123` */
|
|
3858
|
+
/^ember(?:-view|\d+)$/, /** Svelte's scoping hash: `svelte-1a2b3c` */
|
|
3859
|
+
/^svelte-[a-z0-9]+$/i];
|
|
3499
3860
|
/**
|
|
3500
3861
|
* Whether a class token looks hand-authored and safe to build a selector on.
|
|
3501
3862
|
*
|
|
@@ -3802,7 +4163,7 @@ function handleInferSelector(state, command) {
|
|
|
3802
4163
|
reason: 'one of the picked nodes is no longer in the snapshot; take a fresh snapshot and try again'
|
|
3803
4164
|
};
|
|
3804
4165
|
const [pathA, pathB] = paths;
|
|
3805
|
-
if (pathB === undefined) return {
|
|
4166
|
+
if (pathB === undefined) return itemField(html, command, pathA) ?? {
|
|
3806
4167
|
kind: 'field',
|
|
3807
4168
|
field: fieldPick(html, pathA)
|
|
3808
4169
|
};
|
|
@@ -3835,6 +4196,26 @@ function handleInferSelector(state, command) {
|
|
|
3835
4196
|
throw error;
|
|
3836
4197
|
}
|
|
3837
4198
|
}
|
|
4199
|
+
/**
|
|
4200
|
+
* A single pick inside an item of the list the recipe already loops over (`command.within` is the items' selector): the field read from the item,
|
|
4201
|
+
* for the loop's body. `undefined` when no list is given, or the node is not inside one of its items (or is the item itself).
|
|
4202
|
+
*/
|
|
4203
|
+
function itemField(html, command, clicked) {
|
|
4204
|
+
if (command.within === undefined) return undefined;
|
|
4205
|
+
const $ = load(html);
|
|
4206
|
+
let itemId;
|
|
4207
|
+
try {
|
|
4208
|
+
itemId = $(`[${NODE_ID_ATTRIBUTE}="${command.nodeIds[0]}"]`).first().closest(command.within).attr(NODE_ID_ATTRIBUTE);
|
|
4209
|
+
} catch {
|
|
4210
|
+
return undefined;
|
|
4211
|
+
}
|
|
4212
|
+
const itemPath = itemId === undefined ? undefined : pathToNode(html, itemId);
|
|
4213
|
+
if (itemPath === undefined || itemPath.length >= clicked.length) return undefined;
|
|
4214
|
+
return {
|
|
4215
|
+
kind: 'item-field',
|
|
4216
|
+
field: fieldPick(html, clicked.slice(itemPath.length - 1))
|
|
4217
|
+
};
|
|
4218
|
+
}
|
|
3838
4219
|
/** The best selector for one clicked node, `take` decided from the concrete clicked element; `rankSource` (a generalised path) is used for candidate generation when given, so a per-item-unique attribute of the one node picked cannot end up in the selector. */
|
|
3839
4220
|
function fieldPick(html, clicked, rankSource = clicked) {
|
|
3840
4221
|
const leaf = clicked.at(-1);
|
|
@@ -3932,13 +4313,28 @@ function collapseRepeats(nodes) {
|
|
|
3932
4313
|
}
|
|
3933
4314
|
return result;
|
|
3934
4315
|
}
|
|
3935
|
-
/**
|
|
4316
|
+
/** Signatures already worked out: a node is looked at once for its run and again as part of its parent's. */
|
|
4317
|
+
const signatures = new WeakMap();
|
|
4318
|
+
/**
|
|
4319
|
+
* A run member's identity for collapsing: everything but its own `nodeId` (every instance has a different one by
|
|
4320
|
+
* construction). Its children enter as their own short hashes, not as the text of their signatures: nesting that
|
|
4321
|
+
* text in JSON escapes it again at every level, which grows with the depth of the page until the string is too
|
|
4322
|
+
* long to exist (a page nested 40 deep).
|
|
4323
|
+
*/
|
|
3936
4324
|
function signatureOf(node) {
|
|
3937
|
-
|
|
3938
|
-
|
|
4325
|
+
const known = signatures.get(node);
|
|
4326
|
+
if (known !== undefined) return known;
|
|
4327
|
+
const {
|
|
4328
|
+
children,
|
|
4329
|
+
...own
|
|
4330
|
+
} = node;
|
|
4331
|
+
const signature = createHash('sha1').update(JSON.stringify({
|
|
4332
|
+
...own,
|
|
3939
4333
|
nodeId: undefined,
|
|
3940
|
-
children:
|
|
3941
|
-
});
|
|
4334
|
+
children: children.map(child => signatureOf(child))
|
|
4335
|
+
})).digest('hex');
|
|
4336
|
+
signatures.set(node, signature);
|
|
4337
|
+
return signature;
|
|
3942
4338
|
}
|
|
3943
4339
|
function abbreviate(text) {
|
|
3944
4340
|
const trimmed = text.trim().replaceAll(/\s+/g, ' ');
|
|
@@ -4176,6 +4572,19 @@ function handleInspectPage(state, command) {
|
|
|
4176
4572
|
};
|
|
4177
4573
|
}
|
|
4178
4574
|
|
|
4575
|
+
/**
|
|
4576
|
+
* Handles `detect-url`: what the address returns, for "Load ▾ Web page" to start a recipe that reads a document (a JSON API, a PDF,
|
|
4577
|
+
* a spreadsheet, Markdown...) instead of a browser recipe over a response that is not a page.
|
|
4578
|
+
*
|
|
4579
|
+
* @param command - The address.
|
|
4580
|
+
* @returns What it returns.
|
|
4581
|
+
*/
|
|
4582
|
+
async function handleDetectUrl(command) {
|
|
4583
|
+
return {
|
|
4584
|
+
kind: await detectUrlKind(command.url)
|
|
4585
|
+
};
|
|
4586
|
+
}
|
|
4587
|
+
|
|
4179
4588
|
/**
|
|
4180
4589
|
* Handles `open-path`: says what the path is (folder, recipe or document) and
|
|
4181
4590
|
* where to open it. Opens nothing itself: the UI follows with `open-workspace`
|
|
@@ -5321,6 +5730,9 @@ function snapshotView(snapshot) {
|
|
|
5321
5730
|
baseUrl: snapshot.baseUrl,
|
|
5322
5731
|
...(snapshot.format !== undefined && {
|
|
5323
5732
|
format: snapshot.format
|
|
5733
|
+
}),
|
|
5734
|
+
...(snapshot.notice !== undefined && {
|
|
5735
|
+
notice: snapshot.notice
|
|
5324
5736
|
})
|
|
5325
5737
|
};
|
|
5326
5738
|
}
|
|
@@ -5638,6 +6050,10 @@ function dispatch(command, state) {
|
|
|
5638
6050
|
{
|
|
5639
6051
|
return handleFetchStartPage(state, command);
|
|
5640
6052
|
}
|
|
6053
|
+
case 'detect-url':
|
|
6054
|
+
{
|
|
6055
|
+
return handleDetectUrl(command);
|
|
6056
|
+
}
|
|
5641
6057
|
case 'save-outline':
|
|
5642
6058
|
{
|
|
5643
6059
|
return handleSaveOutline(state, command);
|
|
@@ -5773,5 +6189,5 @@ async function main(argv, options = {}) {
|
|
|
5773
6189
|
process.on('SIGTERM', shutdown);
|
|
5774
6190
|
}
|
|
5775
6191
|
|
|
5776
|
-
export { chooseFolderCommandSchema, chooseFolderViewSchema, deckChartSeriesViewSchema, deckChartViewSchema, deckDocumentViewSchema, deckPreviewCommandSchema, deckPreviewOptionsSchema, deckShapeViewSchema, deckSlideViewSchema, deckTablePreviewMatchSchema, deckTablePreviewViewSchema, deckViewCommandSchema, documentTreeCommandSchema, documentTreeNodeViewSchema, documentTreeViewSchema, domTreeNodeViewSchema, explainWhyCommandSchema, fetchStartPage, fetchStartPageCommandSchema, fieldPickSchema, fieldTraceSchema, gridCellViewSchema, gridMergeViewSchema, gridPreviewCommandSchema, gridPreviewOptionsSchema, gridSheetViewSchema, gridTablePreviewMatchSchema, gridTablePreviewViewSchema, gridViewCommandSchema, importDocument, importDocumentCommandSchema, importDocumentViewSchema, inferSelectorCommandSchema, inferSelectorViewSchema, inspectPageCommandSchema, inspectViewSchema, loadRecipePair, loadedHooksSchema, main, missingWhyTargetSchema, observedResponseSchema, openPathCommandSchema, openPathViewSchema, openWorkspace, openWorkspaceCommandSchema, outlineBracketSchema, outlineCardSchema, outlineNodeSchema, outlineViewSchema, pageDataFindingSchema, pdfCellViewSchema, pdfDocumentViewSchema, pdfPageViewSchema, pdfRowViewSchema, pdfViewCommandSchema, recipeIssueSchema, recipeListingSchema, recordEventSchema, recordRejectedEventSchema, recordingCardEventSchema, recordingNoteEventSchema, recordingStoppedEventSchema, regionPreviewCommandSchema, regionPreviewMatchSchema, regionPreviewViewSchema, rejectedWhyTargetSchema, responsesSeenCommandSchema, responsesSeenViewSchema, runFinishedEventSchema, runSample, runSampleCommandSchema, sampleBudgetSchema, sampleRunRecordSchema, sampleRunViewSchema, saveOutlineCommandSchema, saveRecipe, saveRecipeCommandSchema, sentencePartSchema, snapshotViewSchema, startPageViewSchema, startRecordingCommandSchema, startStudioServer, stopRecordingCommandSchema, stopRunCommandSchema, stoppedBySchema, studioCommandSchema, studioEventSchema, tablePreviewBandSchema, tablePreviewCommandSchema, tablePreviewMatchSchema, tablePreviewOptionsSchema, tablePreviewTableSchema, tablePreviewViewSchema, takeSnapshotCommandSchema, traceLineEventSchema, verifySelectorCommandSchema, verifySelectorViewSchema, whyTargetSchema, whyViewSchema, workbookDocumentViewSchema, workspaceChangedEventSchema, workspaceViewSchema };
|
|
6192
|
+
export { chooseFolderCommandSchema, chooseFolderViewSchema, deckChartSeriesViewSchema, deckChartViewSchema, deckDocumentViewSchema, deckPreviewCommandSchema, deckPreviewOptionsSchema, deckShapeViewSchema, deckSlideViewSchema, deckTablePreviewMatchSchema, deckTablePreviewViewSchema, deckViewCommandSchema, detectUrlCommandSchema, detectUrlViewSchema, documentTreeCommandSchema, documentTreeNodeViewSchema, documentTreeViewSchema, domTreeNodeViewSchema, explainWhyCommandSchema, fetchStartPage, fetchStartPageCommandSchema, fieldPickSchema, fieldTraceSchema, gridCellViewSchema, gridMergeViewSchema, gridPreviewCommandSchema, gridPreviewOptionsSchema, gridSheetViewSchema, gridTablePreviewMatchSchema, gridTablePreviewViewSchema, gridViewCommandSchema, importDocument, importDocumentCommandSchema, importDocumentViewSchema, inferSelectorCommandSchema, inferSelectorViewSchema, inspectPageCommandSchema, inspectViewSchema, loadRecipePair, loadedHooksSchema, main, missingWhyTargetSchema, observedResponseSchema, openPathCommandSchema, openPathViewSchema, openWorkspace, openWorkspaceCommandSchema, outlineBracketSchema, outlineCardSchema, outlineNodeSchema, outlineViewSchema, pageDataFindingSchema, pdfCellViewSchema, pdfDocumentViewSchema, pdfPageViewSchema, pdfRowViewSchema, pdfViewCommandSchema, recipeIssueSchema, recipeListingSchema, recordEventSchema, recordRejectedEventSchema, recordingCardEventSchema, recordingNoteEventSchema, recordingStoppedEventSchema, regionPreviewCommandSchema, regionPreviewMatchSchema, regionPreviewViewSchema, rejectedWhyTargetSchema, responsesSeenCommandSchema, responsesSeenViewSchema, runFinishedEventSchema, runSample, runSampleCommandSchema, sampleBudgetSchema, sampleRunRecordSchema, sampleRunViewSchema, saveOutlineCommandSchema, saveRecipe, saveRecipeCommandSchema, sentencePartSchema, snapshotViewSchema, startPageViewSchema, startRecordingCommandSchema, startStudioServer, stopRecordingCommandSchema, stopRunCommandSchema, stoppedBySchema, studioCommandSchema, studioEventSchema, tablePreviewBandSchema, tablePreviewCommandSchema, tablePreviewMatchSchema, tablePreviewOptionsSchema, tablePreviewTableSchema, tablePreviewViewSchema, takeSnapshotCommandSchema, traceLineEventSchema, verifySelectorCommandSchema, verifySelectorViewSchema, whyTargetSchema, whyViewSchema, workbookDocumentViewSchema, workspaceChangedEventSchema, workspaceViewSchema };
|
|
5777
6193
|
//# sourceMappingURL=index.esm.js.map
|