poltertab 1.6.0-beta.1 → 1.6.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/mcp-server/extract-all.js +10 -12
- package/mcp-server/index.js +162 -12
- package/mcp-server/memory.js +208 -4
- package/mcp-server/recipes.js +1043 -0
- package/mcp-server/tools.js +35 -5
- package/package.json +1 -1
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// Primary node — and so the suite can drive it with a scripted transport.
|
|
7
7
|
|
|
8
8
|
const { isBlank } = require("./output.js");
|
|
9
|
+
const { collapsed, ratiosOf } = require("./recipes.js");
|
|
9
10
|
|
|
10
11
|
// --- The pagination loop ---
|
|
11
12
|
//
|
|
@@ -89,22 +90,19 @@ async function extractAll(sendCommand, args) {
|
|
|
89
90
|
// Fill rates against the first page. A spec learned on page 1 degrades
|
|
90
91
|
// quietly later: variant card layouts, a column that stops being populated.
|
|
91
92
|
// Halting beats emitting rows that are 40% empty.
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
93
|
+
//
|
|
94
|
+
// The staleness rule itself lives in recipes.js, because a stored recipe's
|
|
95
|
+
// baseline has to be judged by exactly the same threshold as a paginated
|
|
96
|
+
// run's.
|
|
97
|
+
const ratios = ratiosOf(res);
|
|
96
98
|
if (!baseline) {
|
|
97
99
|
baseline = ratios;
|
|
98
|
-
} else
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
baseline[name] >= 0.5 &&
|
|
102
|
-
ratios[name] < baseline[name] * fill_tolerance,
|
|
103
|
-
);
|
|
104
|
-
if (collapsed.length) {
|
|
100
|
+
} else {
|
|
101
|
+
const stale = collapsed(baseline, ratios, fill_tolerance);
|
|
102
|
+
if (stale.length) {
|
|
105
103
|
stopped_because = "fill_rate_deviation";
|
|
106
104
|
warnings.push(
|
|
107
|
-
`page ${page}: ${
|
|
105
|
+
`page ${page}: ${stale
|
|
108
106
|
.map(
|
|
109
107
|
(n) =>
|
|
110
108
|
`${n} ${(ratios[n] * 100).toFixed(0)}% vs baseline ${(baseline[n] * 100).toFixed(0)}%`,
|
package/mcp-server/index.js
CHANGED
|
@@ -20,7 +20,19 @@ const {
|
|
|
20
20
|
recordSelector,
|
|
21
21
|
noteSelectorFail,
|
|
22
22
|
selectorKey,
|
|
23
|
+
pathPattern,
|
|
24
|
+
listRecipeSummaries,
|
|
23
25
|
} = require("./memory.js");
|
|
26
|
+
const {
|
|
27
|
+
hydrate,
|
|
28
|
+
observe,
|
|
29
|
+
noteStep,
|
|
30
|
+
noteNavigation,
|
|
31
|
+
redactValue,
|
|
32
|
+
hintFor,
|
|
33
|
+
describeRecipes,
|
|
34
|
+
RECIPES_NOTE,
|
|
35
|
+
} = require("./recipes.js");
|
|
24
36
|
const { extractAll } = require("./extract-all.js");
|
|
25
37
|
const bridge = require("./bridge.js");
|
|
26
38
|
const OWN_VERSION = require("./../package.json").version;
|
|
@@ -98,6 +110,65 @@ function pageFor(args) {
|
|
|
98
110
|
return lastPage;
|
|
99
111
|
}
|
|
100
112
|
|
|
113
|
+
// The step buffer's key, resolved by exactly the same rule as pageFor: an
|
|
114
|
+
// explicit session or tab gets its own and never falls through to the
|
|
115
|
+
// process-global one. Without that, two concurrent tasks in two tabs borrow
|
|
116
|
+
// each other's preamble — the O13 failure, one layer up.
|
|
117
|
+
function targetKeyFor(args) {
|
|
118
|
+
const a = args || {};
|
|
119
|
+
if (a.session) return `session:${a.session}`;
|
|
120
|
+
if (a.tabId != null) return `tab:${a.tabId}`;
|
|
121
|
+
return "last";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Actions worth remembering as a recipe's preamble: the facet click, the
|
|
125
|
+
// "100 per page", the scroll that loaded the list.
|
|
126
|
+
const RECORDED = new Set(["click", "fill", "hover", "scroll", "smart_scroll"]);
|
|
127
|
+
|
|
128
|
+
// Bookkeeping for an action that has already happened, so it can never throw
|
|
129
|
+
// at the caller. A fill's value goes through redactValue and nowhere else: the
|
|
130
|
+
// store is a plaintext file in the user's home directory.
|
|
131
|
+
function noteStepFor(action, args, targetKey, page, result) {
|
|
132
|
+
try {
|
|
133
|
+
const { value, redacted } = redactValue(
|
|
134
|
+
action,
|
|
135
|
+
args,
|
|
136
|
+
result && result.fingerprint,
|
|
137
|
+
);
|
|
138
|
+
noteStep(targetKey, {
|
|
139
|
+
action,
|
|
140
|
+
selector: (args && args.selector) || null,
|
|
141
|
+
path: page && page.path,
|
|
142
|
+
host: page && page.host,
|
|
143
|
+
// Nothing at all for a redacted fill, not the null redactValue reports:
|
|
144
|
+
// a stored `value: null` reads back as "this field was filled with
|
|
145
|
+
// nothing", which is a different step from the one that happened.
|
|
146
|
+
value: redacted ? undefined : value,
|
|
147
|
+
redacted,
|
|
148
|
+
});
|
|
149
|
+
} catch (_) {
|
|
150
|
+
// A preamble we failed to record is a recipe with fewer steps, not a
|
|
151
|
+
// failed action.
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Recall has to be implicit — the tool surface is already 23 schemas on every
|
|
156
|
+
// request — so a navigate says what has been learned about where it landed.
|
|
157
|
+
// One memory read, and nothing at all on a host with no recipes.
|
|
158
|
+
function onNavigate(args, targetKey, result) {
|
|
159
|
+
try {
|
|
160
|
+
const dest = pageOf(result && result.url);
|
|
161
|
+
if (!dest) return result;
|
|
162
|
+
noteNavigation(targetKey, dest.host);
|
|
163
|
+
const known = listRecipeSummaries(dest.host, pathPattern(dest.path));
|
|
164
|
+
if (!known.length) return result;
|
|
165
|
+
return { ...result, recipes_available: hintFor(known) };
|
|
166
|
+
} catch (_) {
|
|
167
|
+
// The navigation succeeded; a missing hint is the lesser evil.
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
101
172
|
const handleToolCall = async (request) => {
|
|
102
173
|
const { name, arguments: args } = request.params;
|
|
103
174
|
|
|
@@ -151,6 +222,16 @@ const handleToolCall = async (request) => {
|
|
|
151
222
|
const scrollResult = await bridge.sendCommand("scroll", args || {});
|
|
152
223
|
bridge.noteActiveTab(scrollResult && scrollResult.tabId);
|
|
153
224
|
|
|
225
|
+
// The scroll that loaded the list is part of the preamble a recipe
|
|
226
|
+
// replays, and this branch returns before the generic path can see it.
|
|
227
|
+
noteStepFor(
|
|
228
|
+
"smart_scroll",
|
|
229
|
+
args,
|
|
230
|
+
targetKeyFor(args),
|
|
231
|
+
pageFor(args),
|
|
232
|
+
scrollResult,
|
|
233
|
+
);
|
|
234
|
+
|
|
154
235
|
// Wait for network requests to arrive (lazy loading)
|
|
155
236
|
await new Promise((r) => setTimeout(r, SMART_SCROLL_SETTLE_MS));
|
|
156
237
|
|
|
@@ -187,12 +268,18 @@ const handleToolCall = async (request) => {
|
|
|
187
268
|
if (action === "get_site_memory") {
|
|
188
269
|
const host = args.hostname || args.domain || args.url;
|
|
189
270
|
if (!host) throw new Error("Missing 'hostname' parameter");
|
|
190
|
-
// The agent
|
|
191
|
-
//
|
|
271
|
+
// The agent's own notes, plus what it worked out about extracting the
|
|
272
|
+
// site. The selectors map stays out: it is internal plumbing for
|
|
273
|
+
// self-healing and would just be noise here.
|
|
274
|
+
const memory = readMemory(host);
|
|
275
|
+
const payload = { notes: memory.notes };
|
|
276
|
+
const learned = describeRecipes(memory.recipes);
|
|
277
|
+
if (learned.length) {
|
|
278
|
+
payload.recipes = learned;
|
|
279
|
+
payload.recipes_note = RECIPES_NOTE;
|
|
280
|
+
}
|
|
192
281
|
return {
|
|
193
|
-
content: [
|
|
194
|
-
{ type: "text", text: JSON.stringify(readMemory(host).notes, null, 2) },
|
|
195
|
-
],
|
|
282
|
+
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
|
196
283
|
};
|
|
197
284
|
}
|
|
198
285
|
|
|
@@ -207,12 +294,24 @@ const handleToolCall = async (request) => {
|
|
|
207
294
|
|
|
208
295
|
// Loops in the server, not in the model. One tool call covers every page.
|
|
209
296
|
if (action === "extract_all") {
|
|
210
|
-
const payload = await extractAll(bridge.sendCommand, args || {});
|
|
211
297
|
const opts = args || {};
|
|
298
|
+
const targetKey = targetKeyFor(opts);
|
|
299
|
+
// The page a recipe belongs to comes from the template, not from the tab:
|
|
300
|
+
// this tool drives its own navigation, so the tab can be anywhere. The
|
|
301
|
+
// inner sendCommand("extract", spec) calls always carry an explicit spec
|
|
302
|
+
// and so are never hydrated.
|
|
303
|
+
const page = pageOf(String(opts.url_template || "").replace("{page}", "1"));
|
|
304
|
+
const ctx = hydrate("extract_all", opts, page, targetKey);
|
|
305
|
+
|
|
306
|
+
const payload = await extractAll(bridge.sendCommand, opts);
|
|
307
|
+
const patch = ctx
|
|
308
|
+
? observe({ action, args: opts, result: payload, ctx, targetKey })
|
|
309
|
+
: null;
|
|
310
|
+
const out = patch ? { ...payload, ...patch } : payload;
|
|
212
311
|
|
|
213
312
|
if (opts.output_file) {
|
|
214
|
-
const written = writeOutput(opts.output_file,
|
|
215
|
-
const { rows, pages, ...rest } =
|
|
313
|
+
const written = writeOutput(opts.output_file, out, out.rows);
|
|
314
|
+
const { rows, pages, ...rest } = out;
|
|
216
315
|
const summary = {
|
|
217
316
|
...rest,
|
|
218
317
|
...written,
|
|
@@ -227,7 +326,7 @@ const handleToolCall = async (request) => {
|
|
|
227
326
|
}
|
|
228
327
|
|
|
229
328
|
return {
|
|
230
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
329
|
+
content: [{ type: "text", text: JSON.stringify(out, null, 2) }],
|
|
231
330
|
};
|
|
232
331
|
}
|
|
233
332
|
|
|
@@ -237,8 +336,13 @@ const handleToolCall = async (request) => {
|
|
|
237
336
|
// by the action itself — see memory.selectorKey.
|
|
238
337
|
const healable = action === "click" || action === "fill";
|
|
239
338
|
const selector = args && args.selector;
|
|
240
|
-
|
|
241
|
-
|
|
339
|
+
// Resolved for every action, not just a healable click: the recipe layer
|
|
340
|
+
// is keyed by the same page the selector store is.
|
|
341
|
+
const page = pageFor(args);
|
|
342
|
+
const key =
|
|
343
|
+
healable && selector && page
|
|
344
|
+
? selectorKey(action, page.path, selector)
|
|
345
|
+
: null;
|
|
242
346
|
if (key && !args.fingerprint) {
|
|
243
347
|
// Reading the store must never stop a click that would otherwise work.
|
|
244
348
|
try {
|
|
@@ -249,6 +353,17 @@ const handleToolCall = async (request) => {
|
|
|
249
353
|
}
|
|
250
354
|
}
|
|
251
355
|
|
|
356
|
+
// Recipes live in the generic path on purpose: hydrate a missing extract
|
|
357
|
+
// spec before the command goes out, judge what came back after. A second
|
|
358
|
+
// `if (action === "extract")` branch would need its own copy of
|
|
359
|
+
// output_file, tab tracking and page learning. Throwing before sendCommand
|
|
360
|
+
// is deliberate too — an extract with no record selector is not a cheap
|
|
361
|
+
// mistake, it returns the page's whole text as one row.
|
|
362
|
+
const targetKey = targetKeyFor(args);
|
|
363
|
+
// targetKey, not just the page: hydrate matches a missing `variant`
|
|
364
|
+
// against the steps this server watched the caller issue on this target.
|
|
365
|
+
const ctx = hydrate(action, args, page, targetKey);
|
|
366
|
+
|
|
252
367
|
let result;
|
|
253
368
|
try {
|
|
254
369
|
result = await bridge.sendCommand(action, args || {});
|
|
@@ -279,17 +394,52 @@ const handleToolCall = async (request) => {
|
|
|
279
394
|
// The action succeeded; losing the bookkeeping is the lesser evil.
|
|
280
395
|
}
|
|
281
396
|
|
|
397
|
+
// Recorded only once the browser has confirmed the action, so a stored
|
|
398
|
+
// preamble never replays a click that never landed.
|
|
399
|
+
if (RECORDED.has(action)) {
|
|
400
|
+
noteStepFor(action, args, targetKey, page, result);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// browser_fill echoes back the value it typed. Keeping a password out of
|
|
404
|
+
// the store is no use if the echo puts it in the model's context and from
|
|
405
|
+
// there into the transcript, which is the exposure the redaction denylist
|
|
406
|
+
// exists to close. Only a fill redactValue calls sensitive is touched — a
|
|
407
|
+
// plain fill echoing its value is worth reading. The extension still sends
|
|
408
|
+
// the value over the local WebSocket, so this narrows where a secret lands
|
|
409
|
+
// rather than making the fill secret end to end.
|
|
410
|
+
if (action === "fill" && result && typeof result === "object") {
|
|
411
|
+
try {
|
|
412
|
+
const { redacted } = redactValue(action, args, result.fingerprint);
|
|
413
|
+
if (redacted && "value" in result) {
|
|
414
|
+
result = { ...result, value: "(value withheld)" };
|
|
415
|
+
}
|
|
416
|
+
} catch (_) {
|
|
417
|
+
// Same contract as noteStepFor: the fill has already happened.
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (action === "navigate") result = onNavigate(args, targetKey, result);
|
|
422
|
+
|
|
423
|
+
// Patched before the output_file branch below: a run that writes to disk
|
|
424
|
+
// is exactly the run where the model cannot see the rows for itself, so
|
|
425
|
+
// losing used_recipe/stale there would hide a replay entirely.
|
|
426
|
+
const patch = ctx
|
|
427
|
+
? observe({ action, args, result, ctx, targetKey })
|
|
428
|
+
: null;
|
|
429
|
+
if (patch) result = { ...result, ...patch };
|
|
430
|
+
|
|
282
431
|
// Any read tool can send its payload to disk. Placed after tab tracking so
|
|
283
432
|
// taking the file path does not cost the session its tab bookkeeping.
|
|
284
433
|
if (args && args.output_file && result && typeof result === "object") {
|
|
285
434
|
const rows = rowsOf(result);
|
|
286
435
|
const written = writeOutput(args.output_file, result, rows);
|
|
436
|
+
const summary = summarizeOutput(result, rows, written);
|
|
287
437
|
return {
|
|
288
438
|
content: [
|
|
289
439
|
{
|
|
290
440
|
type: "text",
|
|
291
441
|
text: JSON.stringify(
|
|
292
|
-
|
|
442
|
+
patch ? { ...summary, ...patch } : summary,
|
|
293
443
|
null,
|
|
294
444
|
2,
|
|
295
445
|
),
|
package/mcp-server/memory.js
CHANGED
|
@@ -69,21 +69,40 @@ function memoryFile(rawHost) {
|
|
|
69
69
|
// bound; selectors likewise, evicting the least-recently-used first.
|
|
70
70
|
const NOTES_CAP = 100;
|
|
71
71
|
const SELECTORS_CAP = 200;
|
|
72
|
+
// A learned recipe carries a whole extract spec plus replay steps per variant,
|
|
73
|
+
// so it is orders of magnitude larger than a selector entry — cap it far
|
|
74
|
+
// tighter, and count across path patterns because the file size is what the
|
|
75
|
+
// cap actually protects.
|
|
76
|
+
const RECIPES_CAP = 20;
|
|
77
|
+
// And per recipe: a site with many facet combinations learns one variant per
|
|
78
|
+
// combination under a single name, and evictRecipes counts recipes rather than
|
|
79
|
+
// variants, so without this one recipe on one path pattern grows without
|
|
80
|
+
// bound. Eight is enough slices of one task to be worth keeping.
|
|
81
|
+
const VARIANTS_CAP = 8;
|
|
72
82
|
// A selector that keeps missing even with its fingerprint has drifted past
|
|
73
83
|
// recognition — stop trusting it rather than relocating against a dead signature.
|
|
74
84
|
const MAX_FAILS = 3;
|
|
75
85
|
|
|
76
86
|
// Normalize any on-disk shape — including the original bare array — to the
|
|
77
|
-
// current { notes, selectors } form. Old files upgrade in place on the
|
|
78
|
-
// write, so there is no migration step.
|
|
87
|
+
// current { notes, selectors, recipes } form. Old files upgrade in place on the
|
|
88
|
+
// next write, so there is no migration step.
|
|
79
89
|
function normalize(raw) {
|
|
80
|
-
if (Array.isArray(raw)) return { notes: raw, selectors: {} };
|
|
90
|
+
if (Array.isArray(raw)) return { notes: raw, selectors: {}, recipes: {} };
|
|
81
91
|
return {
|
|
82
92
|
notes: raw && Array.isArray(raw.notes) ? raw.notes : [],
|
|
83
93
|
selectors:
|
|
84
94
|
raw && raw.selectors && typeof raw.selectors === "object"
|
|
85
95
|
? raw.selectors
|
|
86
96
|
: {},
|
|
97
|
+
// An array here would survive the typeof check and then hand out numeric
|
|
98
|
+
// recipe names from Object.keys, so reject anything but a plain object.
|
|
99
|
+
recipes:
|
|
100
|
+
raw &&
|
|
101
|
+
raw.recipes &&
|
|
102
|
+
typeof raw.recipes === "object" &&
|
|
103
|
+
!Array.isArray(raw.recipes)
|
|
104
|
+
? raw.recipes
|
|
105
|
+
: {},
|
|
87
106
|
};
|
|
88
107
|
}
|
|
89
108
|
|
|
@@ -124,9 +143,13 @@ function writeMemory(rawHost, data) {
|
|
|
124
143
|
// The selector store is bookkeeping for an action that already happened, so a
|
|
125
144
|
// failed write must never surface as a failed click — the agent would retry and
|
|
126
145
|
// double-submit. saveMemory stays strict: it IS the user's action.
|
|
146
|
+
//
|
|
147
|
+
// Whatever fn returned comes back out, so noteRecipeFail can report what it
|
|
148
|
+
// deleted. A throw reads as undefined, which is the right answer either way: a
|
|
149
|
+
// write that failed deleted nothing.
|
|
127
150
|
function bestEffort(fn) {
|
|
128
151
|
try {
|
|
129
|
-
fn();
|
|
152
|
+
return fn();
|
|
130
153
|
} catch (_) {
|
|
131
154
|
// Read-only or full home: keep the healing we cannot persist to ourselves.
|
|
132
155
|
}
|
|
@@ -196,6 +219,179 @@ function evictSelectors(selectors) {
|
|
|
196
219
|
.forEach((k) => delete selectors[k]);
|
|
197
220
|
}
|
|
198
221
|
|
|
222
|
+
// ── Learned extraction recipes ──────────────────────────────────────────────
|
|
223
|
+
//
|
|
224
|
+
// A recipe is what the agent worked out about a listing page: the record
|
|
225
|
+
// selector and field map that produced clean rows, and — per variant, a slice
|
|
226
|
+
// of the same task with its own filters — the steps that got there and the
|
|
227
|
+
// fill rate to expect next time. Keyed by path pattern → recipe name →
|
|
228
|
+
// variant, so /jobs/12345 and /jobs/99 share what was learned once.
|
|
229
|
+
|
|
230
|
+
const UUID_SEGMENT =
|
|
231
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
232
|
+
|
|
233
|
+
// Volatile path segments have to collapse or every record page teaches a recipe
|
|
234
|
+
// that nothing ever reads back: /jobs/12345 is the same page shape as /jobs/99,
|
|
235
|
+
// and a recipe learned on one is what makes the other free.
|
|
236
|
+
function pathPattern(rawPath) {
|
|
237
|
+
let p = rawPath == null ? "" : String(rawPath);
|
|
238
|
+
|
|
239
|
+
// Callers pass a pathname, but what the page reported is often the full URL.
|
|
240
|
+
if (p.includes("://")) {
|
|
241
|
+
try {
|
|
242
|
+
p = new URL(p).pathname;
|
|
243
|
+
} catch {
|
|
244
|
+
// Not parseable as a URL; the query/hash strip below still applies.
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
p = p.split("#")[0].split("?")[0];
|
|
248
|
+
|
|
249
|
+
const segments = p
|
|
250
|
+
.split("/")
|
|
251
|
+
.filter(Boolean)
|
|
252
|
+
.map((seg) => {
|
|
253
|
+
if (/^\d+$/.test(seg)) return ":n";
|
|
254
|
+
if (UUID_SEGMENT.test(seg)) return ":id";
|
|
255
|
+
// A long run of letters and digits with no separator is an opaque id.
|
|
256
|
+
// Slugs are excluded by the separator rule on purpose: "senior-engineer-2024"
|
|
257
|
+
// names a page shape, and collapsing it would merge unrelated pages into
|
|
258
|
+
// one recipe.
|
|
259
|
+
if (seg.length >= 12 && /\d/.test(seg) && /^[a-z0-9]+$/i.test(seg))
|
|
260
|
+
return ":id";
|
|
261
|
+
return seg;
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
return segments.length ? `/${segments.join("/")}` : "/";
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// A recipe is as fresh as its freshest variant: one dead filter combination
|
|
268
|
+
// must not make the whole learned spec look stale to eviction.
|
|
269
|
+
function recipeLastOk(recipe) {
|
|
270
|
+
const variants = recipe && recipe.variants ? recipe.variants : {};
|
|
271
|
+
return Object.keys(variants).reduce(
|
|
272
|
+
(max, k) => Math.max(max, (variants[k] && variants[k].lastOk) || 0),
|
|
273
|
+
0,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function getRecipes(rawHost, pattern) {
|
|
278
|
+
return readMemory(rawHost).recipes[pattern] || {};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function getRecipe(rawHost, pattern, name) {
|
|
282
|
+
return getRecipes(rawHost, pattern)[name] || null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Same contract as recordSelector: the extract that taught us this already
|
|
286
|
+
// returned its rows, so a failed write must not surface as a failed extract —
|
|
287
|
+
// the agent would re-run the whole crawl.
|
|
288
|
+
function putRecipe(rawHost, pattern, name, recipe) {
|
|
289
|
+
if (!recipe) return;
|
|
290
|
+
bestEffort(() => {
|
|
291
|
+
const data = readMemory(rawHost);
|
|
292
|
+
const bucket = data.recipes[pattern] || (data.recipes[pattern] = {});
|
|
293
|
+
const existing = bucket[name];
|
|
294
|
+
bucket[name] = {
|
|
295
|
+
extract: recipe.extract || (existing && existing.extract),
|
|
296
|
+
// Merge: variants of one task are learned one run at a time, so writing
|
|
297
|
+
// the second must not forget the first.
|
|
298
|
+
variants: {
|
|
299
|
+
...(existing && existing.variants),
|
|
300
|
+
...(recipe.variants || {}),
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
evictVariants(bucket[name].variants);
|
|
304
|
+
evictRecipes(data.recipes);
|
|
305
|
+
writeMemory(rawHost, data);
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// What was deleted, so the caller can say so: a recipe that vanished under the
|
|
310
|
+
// agent is a recipe it will expect to be there next time. Undefined when the
|
|
311
|
+
// write failed — see bestEffort.
|
|
312
|
+
function noteRecipeFail(rawHost, pattern, name, variant) {
|
|
313
|
+
return bestEffort(() => {
|
|
314
|
+
const data = readMemory(rawHost);
|
|
315
|
+
const bucket = data.recipes[pattern];
|
|
316
|
+
const recipe = bucket && bucket[name];
|
|
317
|
+
const entry = recipe && recipe.variants && recipe.variants[variant];
|
|
318
|
+
if (!entry) return { variant: false, recipe: false, fails: 0 };
|
|
319
|
+
entry.failCount = (entry.failCount || 0) + 1;
|
|
320
|
+
const dropped = { variant: false, recipe: false, fails: entry.failCount };
|
|
321
|
+
if (entry.failCount >= MAX_FAILS) {
|
|
322
|
+
delete recipe.variants[variant];
|
|
323
|
+
dropped.variant = true;
|
|
324
|
+
// Unwind the empty levels above it, or the file accumulates husks that
|
|
325
|
+
// every later read has to page in and every hint has to filter out.
|
|
326
|
+
if (!Object.keys(recipe.variants).length) {
|
|
327
|
+
delete bucket[name];
|
|
328
|
+
dropped.recipe = true;
|
|
329
|
+
}
|
|
330
|
+
if (!Object.keys(bucket).length) delete data.recipes[pattern];
|
|
331
|
+
}
|
|
332
|
+
writeMemory(rawHost, data);
|
|
333
|
+
return dropped;
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Oldest slice of one task first, by the same rule as evictSelectors. Keyed on
|
|
338
|
+
// lastOk rather than on insertion order because the variant nothing has
|
|
339
|
+
// replayed in weeks is the one worth losing.
|
|
340
|
+
function evictVariants(variants) {
|
|
341
|
+
const keys = Object.keys(variants || {});
|
|
342
|
+
if (keys.length <= VARIANTS_CAP) return;
|
|
343
|
+
keys
|
|
344
|
+
.sort(
|
|
345
|
+
(a, b) =>
|
|
346
|
+
((variants[a] && variants[a].lastOk) || 0) -
|
|
347
|
+
((variants[b] && variants[b].lastOk) || 0),
|
|
348
|
+
)
|
|
349
|
+
.slice(0, keys.length - VARIANTS_CAP)
|
|
350
|
+
.forEach((k) => delete variants[k]);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Bound the store the way evictSelectors does, but across path patterns: a
|
|
354
|
+
// crawler that walks a thousand listing URLs would otherwise learn a recipe per
|
|
355
|
+
// pattern and grow one host's file without limit.
|
|
356
|
+
function evictRecipes(recipes) {
|
|
357
|
+
const entries = [];
|
|
358
|
+
for (const pattern of Object.keys(recipes))
|
|
359
|
+
for (const name of Object.keys(recipes[pattern]))
|
|
360
|
+
entries.push({
|
|
361
|
+
pattern,
|
|
362
|
+
name,
|
|
363
|
+
lastOk: recipeLastOk(recipes[pattern][name]),
|
|
364
|
+
});
|
|
365
|
+
if (entries.length <= RECIPES_CAP) return;
|
|
366
|
+
entries
|
|
367
|
+
.sort((a, b) => a.lastOk - b.lastOk)
|
|
368
|
+
.slice(0, entries.length - RECIPES_CAP)
|
|
369
|
+
.forEach(({ pattern, name }) => {
|
|
370
|
+
delete recipes[pattern][name];
|
|
371
|
+
if (!Object.keys(recipes[pattern]).length) delete recipes[pattern];
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// What a recipe offers, without what it costs: the hint that tells an agent a
|
|
376
|
+
// recipe exists is worth nothing if quoting it costs as much context as the
|
|
377
|
+
// extract it saves, so the field map and the replay steps stay on disk.
|
|
378
|
+
function listRecipeSummaries(rawHost, pattern) {
|
|
379
|
+
const bucket = getRecipes(rawHost, pattern);
|
|
380
|
+
return Object.keys(bucket).map((name) => {
|
|
381
|
+
const recipe = bucket[name];
|
|
382
|
+
const fields = recipe && recipe.extract && recipe.extract.fields;
|
|
383
|
+
return {
|
|
384
|
+
name,
|
|
385
|
+
fields:
|
|
386
|
+
fields && typeof fields === "object" && !Array.isArray(fields)
|
|
387
|
+
? Object.keys(fields)
|
|
388
|
+
: [],
|
|
389
|
+
variants: recipe && recipe.variants ? Object.keys(recipe.variants) : [],
|
|
390
|
+
lastOk: recipeLastOk(recipe),
|
|
391
|
+
};
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
199
395
|
module.exports = {
|
|
200
396
|
memoryFile,
|
|
201
397
|
selectorKey,
|
|
@@ -204,4 +400,12 @@ module.exports = {
|
|
|
204
400
|
getSelector,
|
|
205
401
|
recordSelector,
|
|
206
402
|
noteSelectorFail,
|
|
403
|
+
RECIPES_CAP,
|
|
404
|
+
VARIANTS_CAP,
|
|
405
|
+
pathPattern,
|
|
406
|
+
getRecipes,
|
|
407
|
+
getRecipe,
|
|
408
|
+
putRecipe,
|
|
409
|
+
noteRecipeFail,
|
|
410
|
+
listRecipeSummaries,
|
|
207
411
|
};
|