webrecipe 0.1.0
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/LICENSE +21 -0
- package/README.md +253 -0
- package/dist/benchmark/amortization.js +254 -0
- package/dist/benchmark/fixtures.js +26 -0
- package/dist/benchmark/oracles.js +129 -0
- package/dist/benchmark/plans.js +436 -0
- package/dist/fixtures/cloaking.js +37 -0
- package/dist/fixtures/coalesce.js +52 -0
- package/dist/fixtures/data.js +23 -0
- package/dist/fixtures/harness.js +34 -0
- package/dist/fixtures/ignoring.js +27 -0
- package/dist/fixtures/limiting.js +38 -0
- package/dist/fixtures/paging.js +72 -0
- package/dist/fixtures/refusing.js +57 -0
- package/dist/fixtures/shifted.js +32 -0
- package/dist/fixtures/spa.js +71 -0
- package/dist/fixtures/ssr.js +46 -0
- package/dist/fixtures/volatile.js +40 -0
- package/dist/fixtures/xhr.js +120 -0
- package/dist/src/analyzer/classify.js +16 -0
- package/dist/src/analyzer/score.js +52 -0
- package/dist/src/authoring/candidates.js +168 -0
- package/dist/src/authoring/contract.js +31 -0
- package/dist/src/authoring/fields.js +86 -0
- package/dist/src/authoring/learn.js +51 -0
- package/dist/src/authoring/plans.js +93 -0
- package/dist/src/authoring/snapshot.js +22 -0
- package/dist/src/authoring/teach.js +136 -0
- package/dist/src/benchmark/discovery.js +355 -0
- package/dist/src/benchmark/golden.js +95 -0
- package/dist/src/benchmark/grade.js +146 -0
- package/dist/src/benchmark/ground-truth.js +35 -0
- package/dist/src/benchmark/health.js +96 -0
- package/dist/src/benchmark/labels.js +49 -0
- package/dist/src/benchmark/oracle.js +55 -0
- package/dist/src/benchmark/report.js +191 -0
- package/dist/src/benchmark/runner.js +201 -0
- package/dist/src/benchmark/screen.js +144 -0
- package/dist/src/benchmark/selector-score.js +86 -0
- package/dist/src/benchmark/verification-cases.js +138 -0
- package/dist/src/benchmark/verification-matrix.js +97 -0
- package/dist/src/browser/navigate.js +22 -0
- package/dist/src/browser/pool.js +31 -0
- package/dist/src/browser/session.js +44 -0
- package/dist/src/cli.js +559 -0
- package/dist/src/compiler/derive.js +144 -0
- package/dist/src/compiler/heuristic.js +398 -0
- package/dist/src/compiler/html.js +117 -0
- package/dist/src/compiler/types.js +12 -0
- package/dist/src/compiler/verify.js +29 -0
- package/dist/src/executor/extract.js +179 -0
- package/dist/src/executor/format.js +55 -0
- package/dist/src/executor/index.js +147 -0
- package/dist/src/executor/strategies/browser.js +60 -0
- package/dist/src/executor/strategies/http-html.js +42 -0
- package/dist/src/executor/strategies/http-json.js +71 -0
- package/dist/src/executor/strategies/warm-browser.js +57 -0
- package/dist/src/executor/tokens.js +11 -0
- package/dist/src/healing/index.js +111 -0
- package/dist/src/local.js +157 -0
- package/dist/src/mcp.js +130 -0
- package/dist/src/measurement.js +44 -0
- package/dist/src/net/politeness.js +141 -0
- package/dist/src/net/robots.js +56 -0
- package/dist/src/read.js +83 -0
- package/dist/src/recipes/fingerprint.js +41 -0
- package/dist/src/recipes/paths.js +14 -0
- package/dist/src/recipes/registry.js +81 -0
- package/dist/src/recipes/schema.js +38 -0
- package/dist/src/recipes/template.js +33 -0
- package/dist/src/recorder/body.js +59 -0
- package/dist/src/recorder/index.js +151 -0
- package/dist/src/recorder/types.js +1 -0
- package/dist/src/sites.js +45 -0
- package/dist/src/tasks.js +37 -0
- package/dist/src/types.js +32 -0
- package/dist/src/usage.js +69 -0
- package/dist/src/validator/index.js +28 -0
- package/dist/src/verification/lexical-consistency.js +88 -0
- package/dist/src/verification/pagination-honored.js +110 -0
- package/dist/src/verification/probes.js +98 -0
- package/dist/src/verification/query-honored.js +134 -0
- package/dist/src/wiring.js +33 -0
- package/package.json +56 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Learns the derivation a page performs on top of its API.
|
|
3
|
+
*
|
|
4
|
+
* An API is not a machine-readable version of a page; it is the input the page
|
|
5
|
+
* renders from. The page composes: crates.io builds `/crates/serde` from the
|
|
6
|
+
* crate id, and `serde v1.0.229` from a name and a version. A recipe that can
|
|
7
|
+
* only read fields verbatim cannot reproduce those, so it is refused as a
|
|
8
|
+
* narrower replacement for the browser. Synthesising the template recovers them.
|
|
9
|
+
*/
|
|
10
|
+
/** Shorter values match by coincidence far more often than they mean anything. */
|
|
11
|
+
const MIN_VALUE_LENGTH = 2;
|
|
12
|
+
function scalarEntries(row) {
|
|
13
|
+
return Object.entries(row)
|
|
14
|
+
.filter(([, v]) => typeof v === 'string' || typeof v === 'number')
|
|
15
|
+
.map(([k, v]) => [k, String(v)])
|
|
16
|
+
.filter(([, v]) => v.length >= MIN_VALUE_LENGTH);
|
|
17
|
+
}
|
|
18
|
+
function escapeRegExp(value) {
|
|
19
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Rebuilds `target` from the row's own values, longest value first so that a
|
|
23
|
+
* version like `1.0.229` is not broken up by the `1` sitting in another field.
|
|
24
|
+
* Returns null when no value contributes.
|
|
25
|
+
*/
|
|
26
|
+
export function synthesizeTemplate(target, row) {
|
|
27
|
+
const candidates = scalarEntries(row).sort((a, b) => b[1].length - a[1].length);
|
|
28
|
+
let template = target;
|
|
29
|
+
let used = false;
|
|
30
|
+
for (const [key, value] of candidates) {
|
|
31
|
+
if (!template.includes(value))
|
|
32
|
+
continue;
|
|
33
|
+
template = template.replace(new RegExp(escapeRegExp(value), 'g'), `{{${key}}}`);
|
|
34
|
+
used = true;
|
|
35
|
+
}
|
|
36
|
+
return used ? template : null;
|
|
37
|
+
}
|
|
38
|
+
/** Renders a synthesized template back against a row, for verification. */
|
|
39
|
+
export function renderRowTemplate(template, row) {
|
|
40
|
+
// `{{k1 ?? k2}}` renders the first key that has a value, so a page rule
|
|
41
|
+
// conditional on a null field is expressible.
|
|
42
|
+
return template.replace(/\{\{\s*([a-zA-Z0-9_]+(?:\s*\?\?\s*[a-zA-Z0-9_]+)*)\s*\}\}/g, (_match, keys) => {
|
|
43
|
+
for (const key of keys.split('??')) {
|
|
44
|
+
const value = row[key.trim()];
|
|
45
|
+
if (value !== null && value !== undefined && value !== '')
|
|
46
|
+
return String(value);
|
|
47
|
+
}
|
|
48
|
+
return '';
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function placeholderCount(template) {
|
|
52
|
+
return [...template.matchAll(/\{\{/g)].length;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Accepts a template only if it reproduces the browser's value for **every**
|
|
56
|
+
* row. One row cannot tell `id` from `name`, or `objectID` from `story_id`;
|
|
57
|
+
* ten rows can. Where several templates still survive, the choice is made
|
|
58
|
+
* deterministically so that the same trace always compiles to the same recipe.
|
|
59
|
+
*/
|
|
60
|
+
export function synthesizeAcrossRows(targets, rows) {
|
|
61
|
+
if (rows.length === 0 || targets.length !== rows.length)
|
|
62
|
+
return null;
|
|
63
|
+
if (targets.some((t) => t === null || t === undefined))
|
|
64
|
+
return null;
|
|
65
|
+
const strings = targets.map((t) => String(t));
|
|
66
|
+
// Every template that explains the first row is a candidate; the rest filter.
|
|
67
|
+
const seeds = new Set();
|
|
68
|
+
const first = synthesizeTemplate(strings[0], rows[0]);
|
|
69
|
+
if (first === null)
|
|
70
|
+
return null;
|
|
71
|
+
seeds.add(first);
|
|
72
|
+
// Single-field alternatives, so an ambiguous pair is not collapsed too early.
|
|
73
|
+
for (const [key, value] of scalarEntries(rows[0])) {
|
|
74
|
+
if (!strings[0].includes(value))
|
|
75
|
+
continue;
|
|
76
|
+
seeds.add(strings[0].replace(new RegExp(escapeRegExp(value), 'g'), `{{${key}}}`));
|
|
77
|
+
}
|
|
78
|
+
const survives = (template) => strings.every((expected, i) => renderRowTemplate(template, rows[i]) === expected);
|
|
79
|
+
const survivors = [...seeds].filter(survives);
|
|
80
|
+
// Only when nothing plain fits: the page's rule may be conditional on a null
|
|
81
|
+
// field, as bandcamp renders `title by (album_artist ?? band_name)`.
|
|
82
|
+
if (survivors.length === 0) {
|
|
83
|
+
// Across every row, not just the first: the field a page falls back to is
|
|
84
|
+
// null on the rows that made the fallback visible.
|
|
85
|
+
const keys = [...new Set(rows.flatMap((row) => scalarEntries(row).map(([k]) => k)))].sort();
|
|
86
|
+
for (const seed of seeds) {
|
|
87
|
+
for (const slot of seed.matchAll(/\{\{([a-zA-Z0-9_]+)\}\}/g)) {
|
|
88
|
+
const key = slot[1];
|
|
89
|
+
for (const other of keys) {
|
|
90
|
+
if (other === key)
|
|
91
|
+
continue;
|
|
92
|
+
for (const pair of [`${key} ?? ${other}`, `${other} ?? ${key}`]) {
|
|
93
|
+
const candidate = seed.slice(0, slot.index) + `{{${pair}}}`
|
|
94
|
+
+ seed.slice(slot.index + slot[0].length);
|
|
95
|
+
if (survives(candidate))
|
|
96
|
+
survivors.push(candidate);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (survivors.length === 0)
|
|
103
|
+
return null;
|
|
104
|
+
// Prefer the most parameterised survivor. A literal left in the template is a
|
|
105
|
+
// value baked in from the recording: `{{id}} v1.0.229` reproduces the crate it
|
|
106
|
+
// was learned from and reports that same version for every other crate. An
|
|
107
|
+
// over-parameterised template at least varies with the data, and the
|
|
108
|
+
// equivalence check and golden diff still judge it.
|
|
109
|
+
survivors.sort((a, b) => placeholderCount(b) - placeholderCount(a) || a.localeCompare(b));
|
|
110
|
+
return survivors[0];
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Decides what a field's spec should be, given what the browser produced for it.
|
|
114
|
+
*
|
|
115
|
+
* A spec can be absent, or present and wrong: hn.algolia's payload has a `url`,
|
|
116
|
+
* so `url` maps onto it by name, but the page's link is the discussion thread
|
|
117
|
+
* while the payload's is the article. Mapping by name is a guess; the browser's
|
|
118
|
+
* own values are the evidence. Returns null when neither the existing spec nor
|
|
119
|
+
* any synthesis reproduces them.
|
|
120
|
+
*/
|
|
121
|
+
export function reconcileField(spec, browserValues, rows, readSpec = defaultRead) {
|
|
122
|
+
if (spec !== undefined) {
|
|
123
|
+
const reproduces = rows.every((row, i) => {
|
|
124
|
+
const expected = browserValues[i];
|
|
125
|
+
if (expected === null || expected === undefined)
|
|
126
|
+
return false;
|
|
127
|
+
const actual = readSpec(spec, row);
|
|
128
|
+
return actual !== undefined && actual !== null && String(actual) === String(expected);
|
|
129
|
+
});
|
|
130
|
+
if (reproduces)
|
|
131
|
+
return spec;
|
|
132
|
+
}
|
|
133
|
+
return synthesizeAcrossRows(browserValues, rows);
|
|
134
|
+
}
|
|
135
|
+
function defaultRead(spec, row) {
|
|
136
|
+
if (!spec.startsWith('$'))
|
|
137
|
+
return renderRowTemplate(spec, row);
|
|
138
|
+
if (spec === '$')
|
|
139
|
+
return row;
|
|
140
|
+
return spec
|
|
141
|
+
.slice(2)
|
|
142
|
+
.split('.')
|
|
143
|
+
.reduce((node, key) => (node === null || typeof node !== 'object' ? undefined : node[key]), row);
|
|
144
|
+
}
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { scoreRequests } from '../analyzer/score.js';
|
|
2
|
+
import { computeFingerprint } from '../recipes/fingerprint.js';
|
|
3
|
+
import { jsonSignature } from '../executor/extract.js';
|
|
4
|
+
import { resolvePath } from '../recipes/paths.js';
|
|
5
|
+
import { RecipeSchema } from '../recipes/schema.js';
|
|
6
|
+
import { extractJsonItems } from '../executor/extract.js';
|
|
7
|
+
import { verifyAgainstBrowser, browserItemsOf } from './verify.js';
|
|
8
|
+
import { reconcileField } from './derive.js';
|
|
9
|
+
import { readBody } from '../recorder/body.js';
|
|
10
|
+
import { equivalenceRefusal, isRefused } from './types.js';
|
|
11
|
+
/** Values worth substituting; anything shorter matches by coincidence. */
|
|
12
|
+
const MIN_INPUT_LENGTH = 2;
|
|
13
|
+
/** No response got far enough to fail for a reason of its own. */
|
|
14
|
+
export const NO_CANDIDATE = "no candidate response carries the browser's items";
|
|
15
|
+
function escapeRegExp(value) {
|
|
16
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
17
|
+
}
|
|
18
|
+
const TITLE_KEYS = ['title', 'name', 'headline', 'label'];
|
|
19
|
+
const ID_KEYS = ['id', 'slug', 'key', 'uuid'];
|
|
20
|
+
const URL_KEYS = ['url', 'href', 'link', 'permalink'];
|
|
21
|
+
function isObjectArray(value) {
|
|
22
|
+
return (Array.isArray(value) &&
|
|
23
|
+
value.length > 0 &&
|
|
24
|
+
value.every((v) => v !== null && typeof v === 'object' && !Array.isArray(v)));
|
|
25
|
+
}
|
|
26
|
+
/** The longest array of objects at depth 1 or 2 is the result set. */
|
|
27
|
+
export function findItemsPath(payload) {
|
|
28
|
+
if (payload === null || typeof payload !== 'object')
|
|
29
|
+
return null;
|
|
30
|
+
let bestPath = null;
|
|
31
|
+
let bestLength = 0;
|
|
32
|
+
const consider = (path, value) => {
|
|
33
|
+
if (!isObjectArray(value))
|
|
34
|
+
return;
|
|
35
|
+
if (bestPath === null || value.length > bestLength) {
|
|
36
|
+
bestPath = path;
|
|
37
|
+
bestLength = value.length;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
41
|
+
consider(`$.${key}`, value);
|
|
42
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
43
|
+
for (const [k2, v2] of Object.entries(value))
|
|
44
|
+
consider(`$.${key}.${k2}`, v2);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return bestPath;
|
|
48
|
+
}
|
|
49
|
+
const RECORD_KEYS = ['result', 'item', 'record', 'data', 'crate', 'entry'];
|
|
50
|
+
const MIN_RECORD_FIELDS = 2;
|
|
51
|
+
function scalarFieldCount(value) {
|
|
52
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
53
|
+
return 0;
|
|
54
|
+
return Object.values(value).filter((v) => v === null || typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean').length;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* A detail response carries one record, not a list, so there is no array for
|
|
58
|
+
* findItemsPath to latch onto. Picks the wrapped object that looks most like a
|
|
59
|
+
* record, falling back to the payload root when nothing wraps it.
|
|
60
|
+
*/
|
|
61
|
+
export function findRecordPath(payload) {
|
|
62
|
+
if (payload === null || typeof payload !== 'object' || Array.isArray(payload))
|
|
63
|
+
return null;
|
|
64
|
+
const entries = Object.entries(payload);
|
|
65
|
+
for (const key of RECORD_KEYS) {
|
|
66
|
+
const value = payload[key];
|
|
67
|
+
if (scalarFieldCount(value) >= MIN_RECORD_FIELDS)
|
|
68
|
+
return `$.${key}`;
|
|
69
|
+
}
|
|
70
|
+
let bestKey = null;
|
|
71
|
+
let bestCount = 0;
|
|
72
|
+
for (const [key, value] of entries) {
|
|
73
|
+
const count = scalarFieldCount(value);
|
|
74
|
+
if (count >= MIN_RECORD_FIELDS && count > bestCount) {
|
|
75
|
+
bestKey = key;
|
|
76
|
+
bestCount = count;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (bestKey !== null)
|
|
80
|
+
return `$.${bestKey}`;
|
|
81
|
+
return scalarFieldCount(payload) >= MIN_RECORD_FIELDS ? '$' : null;
|
|
82
|
+
}
|
|
83
|
+
function pick(sample, candidates) {
|
|
84
|
+
return candidates.find((k) => k in sample && sample[k] !== null) ?? null;
|
|
85
|
+
}
|
|
86
|
+
const SYNONYMS = {
|
|
87
|
+
id: ID_KEYS,
|
|
88
|
+
title: TITLE_KEYS,
|
|
89
|
+
url: URL_KEYS,
|
|
90
|
+
author: ['author', 'by', 'owner', 'user', 'creator'],
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* When `expected` is given the recipe must be a drop-in replacement for the
|
|
94
|
+
* browser plan, so it maps those exact output names onto whatever the payload
|
|
95
|
+
* calls them. Without it, falls back to the conventional id/title/url guess.
|
|
96
|
+
*/
|
|
97
|
+
export function inferFields(sample, expected) {
|
|
98
|
+
const fields = {};
|
|
99
|
+
if (expected && expected.length > 0) {
|
|
100
|
+
for (const name of expected) {
|
|
101
|
+
const key = pick(sample, SYNONYMS[name] ?? [name]);
|
|
102
|
+
if (key)
|
|
103
|
+
fields[name] = `$.${key}`;
|
|
104
|
+
}
|
|
105
|
+
return fields;
|
|
106
|
+
}
|
|
107
|
+
const id = pick(sample, ID_KEYS) ?? Object.entries(sample).find(([, v]) => typeof v === 'string')?.[0];
|
|
108
|
+
if (id)
|
|
109
|
+
fields.id = `$.${id}`;
|
|
110
|
+
const title = pick(sample, TITLE_KEYS);
|
|
111
|
+
if (title)
|
|
112
|
+
fields.title = `$.${title}`;
|
|
113
|
+
const url = pick(sample, URL_KEYS);
|
|
114
|
+
if (url)
|
|
115
|
+
fields.url = `$.${url}`;
|
|
116
|
+
return fields;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Substitutes input values anywhere in a path, not only as whole segments.
|
|
120
|
+
*
|
|
121
|
+
* A filter is routinely welded into a segment rather than given a query
|
|
122
|
+
* parameter — `/games/genre-puzzle`, `/remote-python-jobs`. Matching whole
|
|
123
|
+
* segments only left those literal, and a recipe carrying no placeholder for
|
|
124
|
+
* its input is refused as a snapshot, so two working sites compiled to nothing.
|
|
125
|
+
*
|
|
126
|
+
* A match must be bounded by a non-word character on both sides, so a value is
|
|
127
|
+
* recognised inside a segment but not inside a longer number or word. Longest
|
|
128
|
+
* value first, so one value is not broken up by a shorter one sitting inside
|
|
129
|
+
* it. Anything that still slips through is caught downstream: a path templated
|
|
130
|
+
* too eagerly returns the wrong thing for a different input and fails the
|
|
131
|
+
* equivalence check.
|
|
132
|
+
*/
|
|
133
|
+
export function templatePath(pathname, input) {
|
|
134
|
+
const values = Object.entries(input)
|
|
135
|
+
.map(([name, v]) => [name, String(v)])
|
|
136
|
+
.filter(([, v]) => v.length >= MIN_INPUT_LENGTH)
|
|
137
|
+
.sort((a, b) => b[1].length - a[1].length);
|
|
138
|
+
let out = pathname;
|
|
139
|
+
for (const [name, value] of values) {
|
|
140
|
+
for (const candidate of [value, encodeURIComponent(value)]) {
|
|
141
|
+
// Bounded by something that is not a word character, so `100` is found in
|
|
142
|
+
// `/item/100` and in `/genre-100` but not inside `/1000`, where
|
|
143
|
+
// substituting it would render `/2000` for an id of 200.
|
|
144
|
+
const bounded = new RegExp(`(?<![A-Za-z0-9])${escapeRegExp(candidate)}(?![A-Za-z0-9])`, 'g');
|
|
145
|
+
out = out.replace(bounded, `{{${name}}}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// A value below the floor would match by coincidence almost anywhere, so it
|
|
149
|
+
// is templated only where it fills an entire path segment: `2` is the page in
|
|
150
|
+
// `/page/2`, but not the tail of `/1000`, the version in `/v2` or part of
|
|
151
|
+
// `/item-2`.
|
|
152
|
+
const short = Object.entries(input)
|
|
153
|
+
.map(([name, v]) => [name, String(v)])
|
|
154
|
+
.filter(([, v]) => v.length > 0 && v.length < MIN_INPUT_LENGTH);
|
|
155
|
+
for (const [name, value] of short) {
|
|
156
|
+
const candidates = new Set([value, encodeURIComponent(value)]);
|
|
157
|
+
out = out
|
|
158
|
+
.split('/')
|
|
159
|
+
.map((segment) => (candidates.has(segment) ? `{{${name}}}` : segment))
|
|
160
|
+
.join('/');
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Finds which query parameter carried an input value and replaces it with a
|
|
166
|
+
* placeholder. A parameter is only templated on an exact value match, so a
|
|
167
|
+
* coincidental substring cannot turn a constant into a variable.
|
|
168
|
+
*/
|
|
169
|
+
function templateQuery(url, input) {
|
|
170
|
+
const query = {};
|
|
171
|
+
for (const [key, value] of url.searchParams.entries()) {
|
|
172
|
+
const match = Object.entries(input).find(([, v]) => String(v) === value);
|
|
173
|
+
query[key] = match ? `{{${match[0]}}}` : value;
|
|
174
|
+
}
|
|
175
|
+
return query;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Templates input values inside a POST body. Algolia and friends put the search
|
|
179
|
+
* term in the body rather than the URL, so a recipe that does not template it
|
|
180
|
+
* returns the results it was recorded with for every query it is ever asked.
|
|
181
|
+
*
|
|
182
|
+
* Only exact whole-value matches are replaced, so prose that merely mentions
|
|
183
|
+
* the query is left alone. Returns null when the body carries no input value.
|
|
184
|
+
*/
|
|
185
|
+
export function templateBody(postData, input) {
|
|
186
|
+
const wanted = Object.entries(input).filter(([, v]) => String(v) !== '');
|
|
187
|
+
if (wanted.length === 0)
|
|
188
|
+
return null;
|
|
189
|
+
const placeholderFor = (value) => {
|
|
190
|
+
const match = wanted.find(([, v]) => String(v) === value);
|
|
191
|
+
return match ? `{{${match[0]}}}` : null;
|
|
192
|
+
};
|
|
193
|
+
try {
|
|
194
|
+
const parsed = JSON.parse(postData);
|
|
195
|
+
let replaced = false;
|
|
196
|
+
const walk = (node) => {
|
|
197
|
+
if (typeof node === 'string') {
|
|
198
|
+
const placeholder = placeholderFor(node);
|
|
199
|
+
if (placeholder === null)
|
|
200
|
+
return node;
|
|
201
|
+
replaced = true;
|
|
202
|
+
return placeholder;
|
|
203
|
+
}
|
|
204
|
+
if (Array.isArray(node))
|
|
205
|
+
return node.map(walk);
|
|
206
|
+
if (node !== null && typeof node === 'object') {
|
|
207
|
+
return Object.fromEntries(Object.entries(node).map(([k, v]) => [k, walk(v)]));
|
|
208
|
+
}
|
|
209
|
+
return node;
|
|
210
|
+
};
|
|
211
|
+
const out = walk(parsed);
|
|
212
|
+
return replaced ? JSON.stringify(out) : null;
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// Not JSON — try form encoding.
|
|
216
|
+
}
|
|
217
|
+
const params = new URLSearchParams(postData);
|
|
218
|
+
let replaced = false;
|
|
219
|
+
for (const [key, value] of [...params.entries()]) {
|
|
220
|
+
const placeholder = placeholderFor(value);
|
|
221
|
+
if (placeholder === null)
|
|
222
|
+
continue;
|
|
223
|
+
params.set(key, placeholder);
|
|
224
|
+
replaced = true;
|
|
225
|
+
}
|
|
226
|
+
return replaced ? params.toString() : null;
|
|
227
|
+
}
|
|
228
|
+
/** Input names that must appear as a placeholder for the recipe to be parameterised. */
|
|
229
|
+
export function templatedInputs(parts) {
|
|
230
|
+
const found = new Set();
|
|
231
|
+
for (const part of parts) {
|
|
232
|
+
for (const match of part.matchAll(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g))
|
|
233
|
+
found.add(match[1]);
|
|
234
|
+
}
|
|
235
|
+
return found;
|
|
236
|
+
}
|
|
237
|
+
export class HeuristicCompiler {
|
|
238
|
+
expectedFields;
|
|
239
|
+
/**
|
|
240
|
+
* `plan` is the browser behaviour the recipe must reproduce. Given one, every
|
|
241
|
+
* candidate is checked against what the browser actually extracted before it
|
|
242
|
+
* is accepted.
|
|
243
|
+
*/
|
|
244
|
+
constructor(expected) {
|
|
245
|
+
if (Array.isArray(expected))
|
|
246
|
+
this.expectedFields = expected;
|
|
247
|
+
else if (expected) {
|
|
248
|
+
this.plan = expected;
|
|
249
|
+
this.expectedFields = Object.keys(expected.fields);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
plan;
|
|
253
|
+
/**
|
|
254
|
+
* Walks candidates in score order and returns the first that yields a usable
|
|
255
|
+
* recipe. Taking only the top scorer makes the compiler give up whenever the
|
|
256
|
+
* highest-ranked request happens not to be parseable — an autocomplete
|
|
257
|
+
* endpoint returning an array of bare strings, say — even though the real
|
|
258
|
+
* result set sits one place below it.
|
|
259
|
+
*/
|
|
260
|
+
async compile(trace) {
|
|
261
|
+
// The first substantive refusal, not the last: candidates are walked in
|
|
262
|
+
// score order, so the highest-ranked response that got far enough to fail
|
|
263
|
+
// for a nameable reason is the one worth reporting.
|
|
264
|
+
let refused = null;
|
|
265
|
+
for (const candidate of scoreRequests(trace)) {
|
|
266
|
+
const outcome = this.compileCandidate(trace, candidate);
|
|
267
|
+
if (outcome === null)
|
|
268
|
+
continue;
|
|
269
|
+
if (isRefused(outcome)) {
|
|
270
|
+
refused ??= outcome.refused;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (!this.plan)
|
|
274
|
+
return outcome;
|
|
275
|
+
const payload = JSON.parse(readBody(candidate.request));
|
|
276
|
+
const browserItems = browserItemsOf(trace, this.plan);
|
|
277
|
+
const { recipe: complete, unreconciled } = this.reconcileFields(outcome, payload, browserItems);
|
|
278
|
+
const items = extractJsonItems(complete, payload);
|
|
279
|
+
if (verifyAgainstBrowser(trace, this.plan, items).equivalent)
|
|
280
|
+
return complete;
|
|
281
|
+
refused ??= unreconciled ?? equivalenceRefusal(items.length, browserItems.length);
|
|
282
|
+
}
|
|
283
|
+
return { refused: refused ?? NO_CANDIDATE };
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Settles every expected field against the values the browser produced.
|
|
287
|
+
*
|
|
288
|
+
* Inferring a field by name is a guess, and it can be absent or simply wrong:
|
|
289
|
+
* hn.algolia's payload carries a `url`, so `url` maps onto it, but the page
|
|
290
|
+
* links to the discussion while the payload holds the article. The browser's
|
|
291
|
+
* own values are the evidence, so each field keeps its inferred spec only if
|
|
292
|
+
* that spec reproduces them, and is otherwise re-derived.
|
|
293
|
+
*
|
|
294
|
+
* Verification still has the last word over the result.
|
|
295
|
+
*/
|
|
296
|
+
reconcileFields(recipe, payload, browserItems) {
|
|
297
|
+
if (!this.expectedFields || recipe.output.type !== 'json')
|
|
298
|
+
return { recipe, unreconciled: null };
|
|
299
|
+
if (browserItems.length === 0)
|
|
300
|
+
return { recipe, unreconciled: null };
|
|
301
|
+
const located = resolvePath(payload, recipe.output.items.path);
|
|
302
|
+
const rows = (Array.isArray(located) ? located : [located]);
|
|
303
|
+
if (rows.length !== browserItems.length)
|
|
304
|
+
return { recipe, unreconciled: null };
|
|
305
|
+
const fields = {};
|
|
306
|
+
let unreconciled = null;
|
|
307
|
+
for (const name of this.expectedFields) {
|
|
308
|
+
const settled = reconcileField(recipe.output.items.fields[name], browserItems.map((item) => item[name] ?? null), rows);
|
|
309
|
+
if (settled !== null)
|
|
310
|
+
fields[name] = settled;
|
|
311
|
+
// Verification still has the last word, but when it goes on to reject the
|
|
312
|
+
// recipe this names the field that could not be made to agree.
|
|
313
|
+
else
|
|
314
|
+
unreconciled ??= `field "${name}" could not be reconciled across ${rows.length} rows`;
|
|
315
|
+
}
|
|
316
|
+
return {
|
|
317
|
+
recipe: { ...recipe, output: { ...recipe.output, items: { ...recipe.output.items, fields } } },
|
|
318
|
+
unreconciled,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
/** null means this response is not a json candidate, which diagnoses nothing. */
|
|
322
|
+
compileCandidate(trace, picked) {
|
|
323
|
+
const raw = readBody(picked.request);
|
|
324
|
+
if (raw === null)
|
|
325
|
+
return null;
|
|
326
|
+
let payload;
|
|
327
|
+
try {
|
|
328
|
+
payload = JSON.parse(raw);
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
// A detail response describes one thing; an array in it is a sidecar
|
|
334
|
+
// (a crate's versions, a package's imports) rather than the answer.
|
|
335
|
+
const itemsPath = trace.intent === 'detail'
|
|
336
|
+
? findRecordPath(payload) ?? findItemsPath(payload)
|
|
337
|
+
: findItemsPath(payload) ?? findRecordPath(payload);
|
|
338
|
+
if (itemsPath === null)
|
|
339
|
+
return { refused: 'items path not found' };
|
|
340
|
+
const located = resolvePath(payload, itemsPath);
|
|
341
|
+
const rows = (Array.isArray(located) ? located : [located]);
|
|
342
|
+
const sample = rows[0];
|
|
343
|
+
if (!sample || typeof sample !== 'object')
|
|
344
|
+
return { refused: 'no object row at the items path' };
|
|
345
|
+
const fields = inferFields(sample, this.expectedFields);
|
|
346
|
+
if (Object.keys(fields).length === 0)
|
|
347
|
+
return { refused: 'no fields could be inferred from the payload' };
|
|
348
|
+
// With a plan, a gap here is not yet fatal: compile() tries to derive the
|
|
349
|
+
// missing field and verification judges the result. Without one there is no
|
|
350
|
+
// safety net, so the field count is all the contract we can enforce.
|
|
351
|
+
if (!this.plan && this.expectedFields && Object.keys(fields).length < this.expectedFields.length) {
|
|
352
|
+
return { refused: `inferred ${Object.keys(fields).length} of ${this.expectedFields.length} expected fields` };
|
|
353
|
+
}
|
|
354
|
+
const url = new URL(picked.request.url);
|
|
355
|
+
const path = templatePath(url.pathname, trace.input);
|
|
356
|
+
const query = templateQuery(url, trace.input);
|
|
357
|
+
const body = picked.request.postData
|
|
358
|
+
? templateBody(picked.request.postData, trace.input) ?? undefined
|
|
359
|
+
: undefined;
|
|
360
|
+
// A recipe that does not carry every input as a placeholder is a snapshot,
|
|
361
|
+
// not a recipe: it returns what it was recorded with whatever it is asked.
|
|
362
|
+
const templated = templatedInputs([path, ...Object.values(query), body ?? '']);
|
|
363
|
+
const required = Object.entries(trace.input).filter(([, v]) => String(v) !== '').map(([k]) => k);
|
|
364
|
+
const untemplated = required.find((name) => !templated.has(name));
|
|
365
|
+
if (untemplated !== undefined)
|
|
366
|
+
return { refused: `required input "${untemplated}" not templated` };
|
|
367
|
+
const contentType = picked.request.requestHeaders['content-type'];
|
|
368
|
+
const outputSpec = { type: 'json', items: { path: itemsPath, fields } };
|
|
369
|
+
const signature = jsonSignature({ output: outputSpec }, payload);
|
|
370
|
+
return RecipeSchema.parse({
|
|
371
|
+
site: trace.site,
|
|
372
|
+
intent: trace.intent,
|
|
373
|
+
inputs: Object.fromEntries(Object.entries(trace.input).map(([k, v]) => [k, { type: typeof v === 'number' ? 'number' : 'string' }])),
|
|
374
|
+
strategy: { type: 'http-json' },
|
|
375
|
+
request: {
|
|
376
|
+
method: picked.request.method === 'POST' ? 'POST' : 'GET',
|
|
377
|
+
// A search vendor's API is a different host than the site itself.
|
|
378
|
+
...(url.origin === trace.origin ? {} : { origin: url.origin }),
|
|
379
|
+
path,
|
|
380
|
+
query,
|
|
381
|
+
...(body === undefined ? {} : { body }),
|
|
382
|
+
...(contentType === undefined ? {} : { headers: { 'content-type': contentType } }),
|
|
383
|
+
},
|
|
384
|
+
output: outputSpec,
|
|
385
|
+
validation: {
|
|
386
|
+
status: 200,
|
|
387
|
+
required: Object.keys(fields).filter((f) => f === 'id' || f === 'title'),
|
|
388
|
+
minItems: 1,
|
|
389
|
+
},
|
|
390
|
+
fingerprint: {
|
|
391
|
+
endpoint: path,
|
|
392
|
+
hash: computeFingerprint(path, signature),
|
|
393
|
+
responseFields: signature.fields,
|
|
394
|
+
},
|
|
395
|
+
fallback: { type: 'browser' },
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { classify } from '../analyzer/classify.js';
|
|
2
|
+
import { scoreRequests } from '../analyzer/score.js';
|
|
3
|
+
import { extractHtmlItems, htmlSignature, parseHtmlFragment } from '../executor/extract.js';
|
|
4
|
+
import { NO_CANDIDATE, templatePath } from './heuristic.js';
|
|
5
|
+
import { browserItemsOf, verifyAgainstBrowser } from './verify.js';
|
|
6
|
+
import { computeFingerprint } from '../recipes/fingerprint.js';
|
|
7
|
+
import { RecipeSchema } from '../recipes/schema.js';
|
|
8
|
+
import { readBody } from '../recorder/body.js';
|
|
9
|
+
import { equivalenceRefusal, isRefused } from './types.js';
|
|
10
|
+
const PLACEHOLDER = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
|
|
11
|
+
function isHtml(request) {
|
|
12
|
+
return request.contentType !== null && /text\/html/.test(request.contentType);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Requests that could carry the items, best first.
|
|
16
|
+
*
|
|
17
|
+
* The navigation is only one candidate. A site may answer a search with an XHR
|
|
18
|
+
* that returns HTML rather than JSON — remoteok.com replies to
|
|
19
|
+
* `?action=get_jobs` with a bare run of table rows — and such a response is
|
|
20
|
+
* reachable by neither compiler: the json one cannot parse it and this one used
|
|
21
|
+
* to look at the navigation alone.
|
|
22
|
+
*/
|
|
23
|
+
function htmlCandidates(trace) {
|
|
24
|
+
const navigation = trace.requests.filter((r) => classify(r) === 'navigation' && r.status === 200);
|
|
25
|
+
const xhr = scoreRequests(trace)
|
|
26
|
+
.map((s) => s.request)
|
|
27
|
+
.filter(isHtml);
|
|
28
|
+
return [...navigation, ...xhr];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Compiles a recipe that fetches HTML and reads the items out of it.
|
|
32
|
+
*
|
|
33
|
+
* Validity means the *raw* response already contains them. The rendered DOM is
|
|
34
|
+
* not evidence: on a SPA it looks identical while the response is empty.
|
|
35
|
+
*/
|
|
36
|
+
export function compileHtmlRecipe(trace, plan) {
|
|
37
|
+
// Candidates are the navigation first, then scored XHR, so the first
|
|
38
|
+
// nameable refusal is the one about the page the task actually asked for.
|
|
39
|
+
let refused = null;
|
|
40
|
+
for (const candidate of htmlCandidates(trace)) {
|
|
41
|
+
const outcome = compileFrom(trace, plan, candidate);
|
|
42
|
+
if (outcome === null)
|
|
43
|
+
continue;
|
|
44
|
+
if (isRefused(outcome)) {
|
|
45
|
+
refused ??= outcome.refused;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
return outcome;
|
|
49
|
+
}
|
|
50
|
+
return { refused: refused ?? NO_CANDIDATE };
|
|
51
|
+
}
|
|
52
|
+
/** null means this response is not an html candidate, which diagnoses nothing. */
|
|
53
|
+
function compileFrom(trace, plan, source) {
|
|
54
|
+
const body = readBody(source);
|
|
55
|
+
if (body === null)
|
|
56
|
+
return null;
|
|
57
|
+
if (parseHtmlFragment(body)(plan.itemSelector).length === 0) {
|
|
58
|
+
return { refused: "items are not in the raw html; only the rendered dom has them" };
|
|
59
|
+
}
|
|
60
|
+
// For a navigation, replay the URL the task asked for rather than the one the
|
|
61
|
+
// server bounced it to: itch.io rewrites /games/tag-puzzle to
|
|
62
|
+
// /games/genre-puzzle, and templating the target gives a recipe that is right
|
|
63
|
+
// for the recorded term and a 404 for every tag that is not also a genre.
|
|
64
|
+
// An XHR was issued by the page itself, so its own URL is the one to replay.
|
|
65
|
+
const isNavigation = classify(source) === 'navigation';
|
|
66
|
+
const requested = isNavigation
|
|
67
|
+
? trace.actions.find((a) => a.type === 'navigate')?.value ?? source.url
|
|
68
|
+
: source.url;
|
|
69
|
+
const url = new URL(requested);
|
|
70
|
+
const path = templatePath(url.pathname, trace.input);
|
|
71
|
+
const query = {};
|
|
72
|
+
for (const [key, value] of url.searchParams.entries()) {
|
|
73
|
+
const match = Object.entries(trace.input).find(([, v]) => String(v) === value);
|
|
74
|
+
query[key] = match ? `{{${match[0]}}}` : templatePath(value, trace.input);
|
|
75
|
+
}
|
|
76
|
+
// An empty spec means "this element's own text" and is a real field, not a hole.
|
|
77
|
+
const fields = { ...plan.fields };
|
|
78
|
+
const draft = {
|
|
79
|
+
site: trace.site,
|
|
80
|
+
intent: trace.intent,
|
|
81
|
+
inputs: Object.fromEntries(Object.entries(trace.input).map(([k, v]) => [k, { type: typeof v === 'number' ? 'number' : 'string' }])),
|
|
82
|
+
strategy: { type: 'http-html' },
|
|
83
|
+
request: {
|
|
84
|
+
method: 'GET',
|
|
85
|
+
...(url.origin === trace.origin ? {} : { origin: url.origin }),
|
|
86
|
+
path,
|
|
87
|
+
query,
|
|
88
|
+
},
|
|
89
|
+
output: { type: 'html', items: { selector: plan.itemSelector, fields } },
|
|
90
|
+
validation: { status: 200, required: Object.keys(fields).slice(0, 2), minItems: 1 },
|
|
91
|
+
fingerprint: { endpoint: path, hash: '', responseFields: [] },
|
|
92
|
+
fallback: { type: 'browser' },
|
|
93
|
+
};
|
|
94
|
+
// Same rule as the json compiler: an untemplated input makes this a snapshot.
|
|
95
|
+
const templated = new Set();
|
|
96
|
+
for (const part of [path, ...Object.values(query)]) {
|
|
97
|
+
for (const m of part.matchAll(PLACEHOLDER))
|
|
98
|
+
templated.add(m[1]);
|
|
99
|
+
}
|
|
100
|
+
const required = Object.entries(trace.input).filter(([, v]) => String(v) !== '').map(([k]) => k);
|
|
101
|
+
const untemplated = required.find((name) => !templated.has(name));
|
|
102
|
+
if (untemplated !== undefined)
|
|
103
|
+
return { refused: `required input "${untemplated}" not templated` };
|
|
104
|
+
const items = extractHtmlItems(draft, body);
|
|
105
|
+
if (!verifyAgainstBrowser(trace, plan, items).equivalent) {
|
|
106
|
+
return { refused: equivalenceRefusal(items.length, browserItemsOf(trace, plan).length) };
|
|
107
|
+
}
|
|
108
|
+
// Fingerprint what the executor will actually see: which declared fields the
|
|
109
|
+
// selectors resolve against this very response.
|
|
110
|
+
const signature = htmlSignature(draft, items);
|
|
111
|
+
draft.fingerprint = {
|
|
112
|
+
endpoint: path,
|
|
113
|
+
hash: computeFingerprint(path, signature),
|
|
114
|
+
responseFields: signature.fields,
|
|
115
|
+
};
|
|
116
|
+
return RecipeSchema.parse(draft);
|
|
117
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function isRefused(result) {
|
|
2
|
+
return 'refused' in result;
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* Equal counts that still do not match are a different diagnosis from a short
|
|
6
|
+
* read, and saying "8 items, browser saw 8" for the first reads as agreement.
|
|
7
|
+
*/
|
|
8
|
+
export function equivalenceRefusal(recipeItems, browserItems) {
|
|
9
|
+
return recipeItems === browserItems
|
|
10
|
+
? `equivalence: recipe and browser both yield ${recipeItems} items, but they differ`
|
|
11
|
+
: `equivalence: recipe yields ${recipeItems} items, browser saw ${browserItems}`;
|
|
12
|
+
}
|