@enricai/barnacle 1.9.2 → 1.9.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/dist/config.d.ts +16 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -0
- package/dist/config.js.map +1 -1
- package/dist/lib/llm/schemas.d.ts +10 -0
- package/dist/lib/llm/schemas.d.ts.map +1 -1
- package/dist/lib/llm/schemas.js +19 -0
- package/dist/lib/llm/schemas.js.map +1 -1
- package/dist/plugins/config-plugin.d.ts +5 -0
- package/dist/plugins/config-plugin.d.ts.map +1 -1
- package/dist/scraper/flow-runner.d.ts +1 -0
- package/dist/scraper/flow-runner.d.ts.map +1 -1
- package/dist/scraper/flow-runner.js +55 -41
- package/dist/scraper/flow-runner.js.map +1 -1
- package/dist/scripts/recon-browser.d.ts +79 -19
- package/dist/scripts/recon-browser.d.ts.map +1 -1
- package/dist/scripts/recon-browser.js +207 -47
- package/dist/scripts/recon-browser.js.map +1 -1
- package/dist/scripts/recon-generate.d.ts +32 -2
- package/dist/scripts/recon-generate.d.ts.map +1 -1
- package/dist/scripts/recon-generate.js +167 -17
- package/dist/scripts/recon-generate.js.map +1 -1
- package/package.json +1 -1
|
@@ -26,6 +26,7 @@ exports.inferZodSchemaFromSamples = inferZodSchemaFromSamples;
|
|
|
26
26
|
exports.selectPayloadAction = selectPayloadAction;
|
|
27
27
|
exports.selectReturnAction = selectReturnAction;
|
|
28
28
|
exports.selectEffectiveResponseBody = selectEffectiveResponseBody;
|
|
29
|
+
exports.resolveManifestActionSequence = resolveManifestActionSequence;
|
|
29
30
|
exports.extractActionSequence = extractActionSequence;
|
|
30
31
|
exports.extractGraphQLActionSequence = extractGraphQLActionSequence;
|
|
31
32
|
exports.detectFormSchemaFieldNames = detectFormSchemaFieldNames;
|
|
@@ -344,7 +345,7 @@ const IGNORE_REQUEST_HEADERS = new Set([
|
|
|
344
345
|
* headers (a `X-CSRF-Token`, a `Job-Boards-API-Token`, an `API-ShortName`,
|
|
345
346
|
* etc.) without the generator needing to know about any particular site.
|
|
346
347
|
*/
|
|
347
|
-
function deriveRequestHeaders(captures, replays, baseUrl) {
|
|
348
|
+
function deriveRequestHeaders(captures, replays, baseUrl, submitPatterns = null) {
|
|
348
349
|
const successfulUrls = new Set(replays.filter((r) => r.success).map((r) => endpointKey(r.url)));
|
|
349
350
|
// Prefer ACTION captures (non-GET 2xx to baseUrl host, non-telemetry) as
|
|
350
351
|
// the authoritative header source. Replay-matched static-asset GETs lack
|
|
@@ -353,7 +354,7 @@ function deriveRequestHeaders(captures, replays, baseUrl) {
|
|
|
353
354
|
// captures exist (multi-step submission flows), use them. For sites
|
|
354
355
|
// where the flow is a single REST call (no detectable action sequence),
|
|
355
356
|
// fall back to the replay-matched captures.
|
|
356
|
-
const actionCaptures = extractActionSequence(captures, baseUrl).map((a) => a.capture);
|
|
357
|
+
const actionCaptures = extractActionSequence(captures, baseUrl, submitPatterns).map((a) => a.capture);
|
|
357
358
|
const replayMatchedCaptures = captures.filter((c) => successfulUrls.has(endpointKey(c.url)));
|
|
358
359
|
const relevantCaptures = actionCaptures.length > 0 ? actionCaptures : replayMatchedCaptures;
|
|
359
360
|
const counts = new Map();
|
|
@@ -409,16 +410,75 @@ function firstEndpointPath(captures) {
|
|
|
409
410
|
}
|
|
410
411
|
return "/api/search";
|
|
411
412
|
}
|
|
413
|
+
/**
|
|
414
|
+
* Builds the compiled submit-pattern predicate. A flow-declared regex that
|
|
415
|
+
* fails to compile is a broken flow (recon-browser validates it eagerly too),
|
|
416
|
+
* so we let the `RegExp` constructor throw rather than silently reverting to
|
|
417
|
+
* unfiltered selection, which would re-admit the page-chrome bloat this gate
|
|
418
|
+
* exists to remove.
|
|
419
|
+
*/
|
|
420
|
+
function compileSubmitMatcher(patterns) {
|
|
421
|
+
if (patterns === null || (patterns.endpoint === null && patterns.body === null)) {
|
|
422
|
+
return () => true;
|
|
423
|
+
}
|
|
424
|
+
const endpointRx = patterns.endpoint === null ? null : new RegExp(patterns.endpoint);
|
|
425
|
+
const bodyRx = patterns.body === null ? null : new RegExp(patterns.body);
|
|
426
|
+
return (capture) => {
|
|
427
|
+
if (endpointRx !== null && !endpointRx.test(capture.url))
|
|
428
|
+
return false;
|
|
429
|
+
if (bodyRx !== null && !bodyRx.test(capture.requestPostData ?? ""))
|
|
430
|
+
return false;
|
|
431
|
+
return true;
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Reads `submit-manifest.json` (written by recon-browser) and resolves it to the
|
|
436
|
+
* authoritative submission action sequence. This is the deepest submit-selection
|
|
437
|
+
* signal: recon-browser matched these captures against the flow's declared submit
|
|
438
|
+
* patterns at run time, so generate emits exactly them instead of re-deriving the
|
|
439
|
+
* submission from raw traffic. Returns null when no manifest exists (older runs,
|
|
440
|
+
* `recon-http`-only) so the caller falls back to pattern/heuristic extraction.
|
|
441
|
+
*
|
|
442
|
+
* The manifest's `index` is the capture's sort-order position, and `captures`
|
|
443
|
+
* arrives already `.sort()`ed by `readJsonDir`, so `captures[index]` is the same
|
|
444
|
+
* capture recon-browser recorded — cross-checked on `url` as a guard against a
|
|
445
|
+
* capture set that drifted between runs.
|
|
446
|
+
*/
|
|
447
|
+
function resolveManifestActionSequence(runRoot, captures) {
|
|
448
|
+
const manifestPath = (0, node_path_1.join)(runRoot, "submit-manifest.json");
|
|
449
|
+
let entries;
|
|
450
|
+
try {
|
|
451
|
+
entries = JSON.parse((0, node_fs_1.readFileSync)(manifestPath, "utf8"));
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
if (!Array.isArray(entries) || entries.length === 0)
|
|
457
|
+
return null;
|
|
458
|
+
const resolved = [];
|
|
459
|
+
for (const entry of entries) {
|
|
460
|
+
const capture = captures[entry.index];
|
|
461
|
+
if (capture === undefined || capture.url !== entry.url)
|
|
462
|
+
return null;
|
|
463
|
+
resolved.push({ capture, index: entry.index });
|
|
464
|
+
}
|
|
465
|
+
return resolved;
|
|
466
|
+
}
|
|
412
467
|
/**
|
|
413
468
|
* Extracts the ordered sequence of meaningful POSTs that represent the
|
|
414
469
|
* transactional flow: same-host 2xx POSTs, minus telemetry and error-reporting
|
|
415
470
|
* sinks. Assets need no filter of their own — they arrive as GETs.
|
|
416
471
|
*
|
|
472
|
+
* When the flow declares submit patterns, only POSTs matching them survive —
|
|
473
|
+
* this isolates the submission from same-origin page chrome (bootstrap, chatbot,
|
|
474
|
+
* JWT refresh, reference-lookup) that a browser fires incidentally. Absent
|
|
475
|
+
* patterns preserve the host/noise heuristic exactly.
|
|
476
|
+
*
|
|
417
477
|
* Exported for tests: this predicate decides what a generated plugin will POST
|
|
418
478
|
* at a live site, and it is the only gate between a browser's incidental
|
|
419
479
|
* chatter and the emitted hot path.
|
|
420
480
|
*/
|
|
421
|
-
function extractActionSequence(captures, baseUrl) {
|
|
481
|
+
function extractActionSequence(captures, baseUrl, submitPatterns = null) {
|
|
422
482
|
let host;
|
|
423
483
|
try {
|
|
424
484
|
host = new URL(baseUrl).host;
|
|
@@ -426,6 +486,7 @@ function extractActionSequence(captures, baseUrl) {
|
|
|
426
486
|
catch {
|
|
427
487
|
host = "";
|
|
428
488
|
}
|
|
489
|
+
const matchesSubmit = compileSubmitMatcher(submitPatterns);
|
|
429
490
|
return captures
|
|
430
491
|
.map((capture, index) => ({ capture, index }))
|
|
431
492
|
.filter(({ capture }) => {
|
|
@@ -444,6 +505,8 @@ function extractActionSequence(captures, baseUrl) {
|
|
|
444
505
|
return false;
|
|
445
506
|
if ((0, capture_filters_1.isNoiseUrl)(capture.url))
|
|
446
507
|
return false;
|
|
508
|
+
if (!matchesSubmit(capture))
|
|
509
|
+
return false;
|
|
447
510
|
return true;
|
|
448
511
|
});
|
|
449
512
|
}
|
|
@@ -460,7 +523,7 @@ function extractActionSequence(captures, baseUrl) {
|
|
|
460
523
|
* Exported for tests: this predicate decides what a generated GraphQL plugin
|
|
461
524
|
* will send at a live site.
|
|
462
525
|
*/
|
|
463
|
-
function extractGraphQLActionSequence(captures, baseUrl) {
|
|
526
|
+
function extractGraphQLActionSequence(captures, baseUrl, submitPatterns = null) {
|
|
464
527
|
let host;
|
|
465
528
|
try {
|
|
466
529
|
host = new URL(baseUrl).host;
|
|
@@ -468,6 +531,7 @@ function extractGraphQLActionSequence(captures, baseUrl) {
|
|
|
468
531
|
catch {
|
|
469
532
|
host = "";
|
|
470
533
|
}
|
|
534
|
+
const matchesSubmit = compileSubmitMatcher(submitPatterns);
|
|
471
535
|
return captures
|
|
472
536
|
.map((capture, index) => ({ capture, index }))
|
|
473
537
|
.filter(({ capture }) => {
|
|
@@ -486,6 +550,8 @@ function extractGraphQLActionSequence(captures, baseUrl) {
|
|
|
486
550
|
return false;
|
|
487
551
|
if ((0, capture_filters_1.isNoiseUrl)(capture.url))
|
|
488
552
|
return false;
|
|
553
|
+
if (!matchesSubmit(capture))
|
|
554
|
+
return false;
|
|
489
555
|
return capture.query !== null && /^\s*mutation\b/.test(capture.query);
|
|
490
556
|
});
|
|
491
557
|
}
|
|
@@ -533,6 +599,34 @@ function* walkStringLeaves(value, path = []) {
|
|
|
533
599
|
}
|
|
534
600
|
}
|
|
535
601
|
}
|
|
602
|
+
/**
|
|
603
|
+
* Parses a captured request body as JSON and returns its string leaf *values*
|
|
604
|
+
* (object keys are excluded — a key is never a JSON value). Returns null when
|
|
605
|
+
* the body is absent or not JSON (e.g. multipart raw bytes), so callers can
|
|
606
|
+
* fall back to whole-body substring matching. This lets the produces-filter
|
|
607
|
+
* match state values against JSON values only, so a response string that
|
|
608
|
+
* appears downstream solely in a JSON *key* position is not mistaken for a
|
|
609
|
+
* reused value — while a value legitimately embedded inside a longer value
|
|
610
|
+
* still matches via substring.
|
|
611
|
+
*/
|
|
612
|
+
function jsonBodyLeafValues(requestPostData) {
|
|
613
|
+
if (typeof requestPostData !== "string" || requestPostData.length === 0)
|
|
614
|
+
return null;
|
|
615
|
+
const parsed = (() => {
|
|
616
|
+
try {
|
|
617
|
+
return JSON.parse(requestPostData);
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
return undefined;
|
|
621
|
+
}
|
|
622
|
+
})();
|
|
623
|
+
if (parsed === undefined)
|
|
624
|
+
return null;
|
|
625
|
+
const values = [];
|
|
626
|
+
for (const { value } of walkStringLeaves(parsed))
|
|
627
|
+
values.push(value);
|
|
628
|
+
return values;
|
|
629
|
+
}
|
|
536
630
|
/**
|
|
537
631
|
* Yields every primitive leaf (string, number, boolean, null) in the JSON
|
|
538
632
|
* value with its path. Used by the body-literal substitution pass to find
|
|
@@ -1314,12 +1408,31 @@ function compileActionSteps(actions, stateIndex) {
|
|
|
1314
1408
|
// Pre-scan: collect all state values referenced by ANY action's URL/headers/body
|
|
1315
1409
|
// so we only "produce" the values that are actually consumed downstream.
|
|
1316
1410
|
for (const { capture } of actions) {
|
|
1317
|
-
const
|
|
1318
|
-
if (capture.requestPostData)
|
|
1319
|
-
haystacks.push(capture.requestPostData);
|
|
1411
|
+
const bodyLeafValues = jsonBodyLeafValues(capture.requestPostData);
|
|
1320
1412
|
for (const sv of stateIndex.values()) {
|
|
1321
|
-
if (
|
|
1413
|
+
if (capture.url.includes(sv.value)) {
|
|
1414
|
+
usedValues.add(sv.value);
|
|
1415
|
+
continue;
|
|
1416
|
+
}
|
|
1417
|
+
// Body consumption is matched against JSON *values* only, never keys: a
|
|
1418
|
+
// state value is a real cross-step dependency when a later request
|
|
1419
|
+
// re-sends it inside a JSON value — either standalone or embedded in a
|
|
1420
|
+
// composite value (e.g. a jobId reused inside a longer jobSeqNo). A
|
|
1421
|
+
// match that lands on a JSON *key* is not reuse: binding then splicing it
|
|
1422
|
+
// would emit a variable into a key position and produce uncompilable
|
|
1423
|
+
// `"${var}":…` / `${${var}}` (e.g. a response echoing field NAMES like
|
|
1424
|
+
// `tokens:["firstName","lastName"]` — those strings appear downstream
|
|
1425
|
+
// only as keys, never within any value). Object keys are never JSON
|
|
1426
|
+
// string leaves, so testing against leaf values alone excludes them while
|
|
1427
|
+
// preserving substring-in-value reuse. Non-JSON bodies (multipart raw
|
|
1428
|
+
// bytes) keep whole-body substring matching.
|
|
1429
|
+
if (bodyLeafValues === null) {
|
|
1430
|
+
if (capture.requestPostData?.includes(sv.value))
|
|
1431
|
+
usedValues.add(sv.value);
|
|
1432
|
+
}
|
|
1433
|
+
else if (bodyLeafValues.some((leaf) => leaf.includes(sv.value))) {
|
|
1322
1434
|
usedValues.add(sv.value);
|
|
1435
|
+
}
|
|
1323
1436
|
}
|
|
1324
1437
|
for (const [headerName, headerValue] of Object.entries(capture.requestHeaders)) {
|
|
1325
1438
|
for (const sv of stateIndex.values()) {
|
|
@@ -2814,25 +2927,52 @@ async function main() {
|
|
|
2814
2927
|
return [];
|
|
2815
2928
|
}
|
|
2816
2929
|
})();
|
|
2817
|
-
const { flowSteps, frameSelector } = (() => {
|
|
2930
|
+
const { flowSteps, frameSelector, submitEndpointPattern, submitBodyPattern } = (() => {
|
|
2818
2931
|
const flowFile = `src/sites/${siteId}/recon-flow.json`;
|
|
2819
2932
|
try {
|
|
2820
2933
|
const raw = JSON.parse((0, node_fs_1.readFileSync)(flowFile, "utf8"));
|
|
2821
2934
|
if (Array.isArray(raw))
|
|
2822
|
-
return {
|
|
2935
|
+
return {
|
|
2936
|
+
flowSteps: raw,
|
|
2937
|
+
frameSelector: undefined,
|
|
2938
|
+
submitEndpointPattern: null,
|
|
2939
|
+
submitBodyPattern: null,
|
|
2940
|
+
};
|
|
2823
2941
|
if (raw !== null &&
|
|
2824
2942
|
typeof raw === "object" &&
|
|
2825
2943
|
"steps" in raw &&
|
|
2826
2944
|
Array.isArray(raw.steps)) {
|
|
2827
2945
|
const obj = raw;
|
|
2828
|
-
return {
|
|
2946
|
+
return {
|
|
2947
|
+
flowSteps: obj.steps,
|
|
2948
|
+
frameSelector: obj.frameSelector,
|
|
2949
|
+
submitEndpointPattern: obj.submitEndpointPattern ?? null,
|
|
2950
|
+
submitBodyPattern: obj.submitBodyPattern ?? null,
|
|
2951
|
+
};
|
|
2829
2952
|
}
|
|
2830
|
-
return {
|
|
2953
|
+
return {
|
|
2954
|
+
flowSteps: [],
|
|
2955
|
+
frameSelector: undefined,
|
|
2956
|
+
submitEndpointPattern: null,
|
|
2957
|
+
submitBodyPattern: null,
|
|
2958
|
+
};
|
|
2831
2959
|
}
|
|
2832
2960
|
catch {
|
|
2833
|
-
return {
|
|
2961
|
+
return {
|
|
2962
|
+
flowSteps: [],
|
|
2963
|
+
frameSelector: undefined,
|
|
2964
|
+
submitEndpointPattern: null,
|
|
2965
|
+
submitBodyPattern: null,
|
|
2966
|
+
};
|
|
2834
2967
|
}
|
|
2835
2968
|
})();
|
|
2969
|
+
// Flow-declared signals that isolate the submission POSTs from same-origin
|
|
2970
|
+
// page chrome. Threaded into action-sequence extraction and header derivation
|
|
2971
|
+
// so both draw from the real submission, not incidental widget/chatbot POSTs.
|
|
2972
|
+
const submitPatterns = {
|
|
2973
|
+
endpoint: submitEndpointPattern,
|
|
2974
|
+
body: submitBodyPattern,
|
|
2975
|
+
};
|
|
2836
2976
|
// Resolved once and threaded down, never captured into a module const: a
|
|
2837
2977
|
// module-level const would freeze at import time, which is the bug that makes
|
|
2838
2978
|
// RECON_QUESTION_KEYWORDS silently inert for anyone setting it after load.
|
|
@@ -2842,7 +2982,7 @@ async function main() {
|
|
|
2842
2982
|
const formSchema = await resolveFormSchema(formSchemaSpecifier);
|
|
2843
2983
|
const pascal = toPascalCase(siteId);
|
|
2844
2984
|
const baseUrl = deriveBaseUrl(captures);
|
|
2845
|
-
const baseHeaders = deriveRequestHeaders(captures, replays, baseUrl);
|
|
2985
|
+
const baseHeaders = deriveRequestHeaders(captures, replays, baseUrl, submitPatterns);
|
|
2846
2986
|
const minTime = deriveMinTime(rateLimits);
|
|
2847
2987
|
const safeRps = rateLimits.find((f) => f.safeRps !== null)?.safeRps ?? Math.floor(1000 / minTime);
|
|
2848
2988
|
const responseBody = firstSuccessfulReplayBody(replays);
|
|
@@ -2852,9 +2992,19 @@ async function main() {
|
|
|
2852
2992
|
// Detect a multi-step submission flow (transactional sites like apply forms,
|
|
2853
2993
|
// checkout, etc.). When the action sequence has 2+ POSTs, switch the
|
|
2854
2994
|
// contract template to emit a state-threaded executeHttp.
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2995
|
+
//
|
|
2996
|
+
// Selection precedence: (A) the authoritative submit-manifest recon-browser
|
|
2997
|
+
// wrote from the verified submission; else (B/C) pattern/heuristic extraction.
|
|
2998
|
+
// The manifest is the only signal that separates a submission POST from a
|
|
2999
|
+
// page-chrome POST sharing its URL, so it wins when present.
|
|
3000
|
+
const manifestActionCaptures = resolveManifestActionSequence(runRoot, captures);
|
|
3001
|
+
if (manifestActionCaptures !== null) {
|
|
3002
|
+
logger.info(`submission selection: using submit-manifest.json (${manifestActionCaptures.length} authoritative capture(s))`);
|
|
3003
|
+
}
|
|
3004
|
+
const rawActionCaptures = manifestActionCaptures ??
|
|
3005
|
+
(gql
|
|
3006
|
+
? extractGraphQLActionSequence(captures, baseUrl, submitPatterns)
|
|
3007
|
+
: collapseRedundantPatches(extractActionSequence(captures, baseUrl, submitPatterns)));
|
|
2858
3008
|
// Form-schema detection runs BEFORE state-indexing so the field-id/option-id
|
|
2859
3009
|
// UUIDs can be shielded from indexing — those UUIDs are stable schema
|
|
2860
3010
|
// anchors that T2/T3 substitution depends on remaining literal in body
|