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.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +253 -0
  3. package/dist/benchmark/amortization.js +254 -0
  4. package/dist/benchmark/fixtures.js +26 -0
  5. package/dist/benchmark/oracles.js +129 -0
  6. package/dist/benchmark/plans.js +436 -0
  7. package/dist/fixtures/cloaking.js +37 -0
  8. package/dist/fixtures/coalesce.js +52 -0
  9. package/dist/fixtures/data.js +23 -0
  10. package/dist/fixtures/harness.js +34 -0
  11. package/dist/fixtures/ignoring.js +27 -0
  12. package/dist/fixtures/limiting.js +38 -0
  13. package/dist/fixtures/paging.js +72 -0
  14. package/dist/fixtures/refusing.js +57 -0
  15. package/dist/fixtures/shifted.js +32 -0
  16. package/dist/fixtures/spa.js +71 -0
  17. package/dist/fixtures/ssr.js +46 -0
  18. package/dist/fixtures/volatile.js +40 -0
  19. package/dist/fixtures/xhr.js +120 -0
  20. package/dist/src/analyzer/classify.js +16 -0
  21. package/dist/src/analyzer/score.js +52 -0
  22. package/dist/src/authoring/candidates.js +168 -0
  23. package/dist/src/authoring/contract.js +31 -0
  24. package/dist/src/authoring/fields.js +86 -0
  25. package/dist/src/authoring/learn.js +51 -0
  26. package/dist/src/authoring/plans.js +93 -0
  27. package/dist/src/authoring/snapshot.js +22 -0
  28. package/dist/src/authoring/teach.js +136 -0
  29. package/dist/src/benchmark/discovery.js +355 -0
  30. package/dist/src/benchmark/golden.js +95 -0
  31. package/dist/src/benchmark/grade.js +146 -0
  32. package/dist/src/benchmark/ground-truth.js +35 -0
  33. package/dist/src/benchmark/health.js +96 -0
  34. package/dist/src/benchmark/labels.js +49 -0
  35. package/dist/src/benchmark/oracle.js +55 -0
  36. package/dist/src/benchmark/report.js +191 -0
  37. package/dist/src/benchmark/runner.js +201 -0
  38. package/dist/src/benchmark/screen.js +144 -0
  39. package/dist/src/benchmark/selector-score.js +86 -0
  40. package/dist/src/benchmark/verification-cases.js +138 -0
  41. package/dist/src/benchmark/verification-matrix.js +97 -0
  42. package/dist/src/browser/navigate.js +22 -0
  43. package/dist/src/browser/pool.js +31 -0
  44. package/dist/src/browser/session.js +44 -0
  45. package/dist/src/cli.js +559 -0
  46. package/dist/src/compiler/derive.js +144 -0
  47. package/dist/src/compiler/heuristic.js +398 -0
  48. package/dist/src/compiler/html.js +117 -0
  49. package/dist/src/compiler/types.js +12 -0
  50. package/dist/src/compiler/verify.js +29 -0
  51. package/dist/src/executor/extract.js +179 -0
  52. package/dist/src/executor/format.js +55 -0
  53. package/dist/src/executor/index.js +147 -0
  54. package/dist/src/executor/strategies/browser.js +60 -0
  55. package/dist/src/executor/strategies/http-html.js +42 -0
  56. package/dist/src/executor/strategies/http-json.js +71 -0
  57. package/dist/src/executor/strategies/warm-browser.js +57 -0
  58. package/dist/src/executor/tokens.js +11 -0
  59. package/dist/src/healing/index.js +111 -0
  60. package/dist/src/local.js +157 -0
  61. package/dist/src/mcp.js +130 -0
  62. package/dist/src/measurement.js +44 -0
  63. package/dist/src/net/politeness.js +141 -0
  64. package/dist/src/net/robots.js +56 -0
  65. package/dist/src/read.js +83 -0
  66. package/dist/src/recipes/fingerprint.js +41 -0
  67. package/dist/src/recipes/paths.js +14 -0
  68. package/dist/src/recipes/registry.js +81 -0
  69. package/dist/src/recipes/schema.js +38 -0
  70. package/dist/src/recipes/template.js +33 -0
  71. package/dist/src/recorder/body.js +59 -0
  72. package/dist/src/recorder/index.js +151 -0
  73. package/dist/src/recorder/types.js +1 -0
  74. package/dist/src/sites.js +45 -0
  75. package/dist/src/tasks.js +37 -0
  76. package/dist/src/types.js +32 -0
  77. package/dist/src/usage.js +69 -0
  78. package/dist/src/validator/index.js +28 -0
  79. package/dist/src/verification/lexical-consistency.js +88 -0
  80. package/dist/src/verification/pagination-honored.js +110 -0
  81. package/dist/src/verification/probes.js +98 -0
  82. package/dist/src/verification/query-honored.js +134 -0
  83. package/dist/src/wiring.js +33 -0
  84. 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
+ }