poltertab 1.4.0 → 1.5.0-beta.1
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/README.md +7 -16
- package/mcp-server/bridge.js +593 -0
- package/mcp-server/config.js +85 -0
- package/mcp-server/extract-all.js +159 -0
- package/mcp-server/index.js +25 -1333
- package/mcp-server/memory.js +81 -0
- package/mcp-server/output.js +103 -0
- package/mcp-server/tools.js +439 -0
- package/package.json +1 -2
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Every tunable the Node half has, in one place, because several of them are
|
|
2
|
+
// only correct relative to each other and that relationship was previously
|
|
3
|
+
// unwritten — three copies of a 35s timeout in one file, a port in five.
|
|
4
|
+
//
|
|
5
|
+
// ── The timeout budget ──────────────────────────────────────────────────────
|
|
6
|
+
//
|
|
7
|
+
// A DOM command crosses four processes, and each layer waits on the one below:
|
|
8
|
+
//
|
|
9
|
+
// MCP server COMMAND_TIMEOUT_MS 35s ← must be the largest
|
|
10
|
+
// extension navigation wait 30s + 500ms settle (background.js)
|
|
11
|
+
// frame postToFrame 10s (background.js)
|
|
12
|
+
// page ELEMENT_WAIT_MS 3s (content_script.js)
|
|
13
|
+
//
|
|
14
|
+
// The invariant: **each layer's budget must exceed the sum of what it waits
|
|
15
|
+
// on.** Break it and a slow-but-successful page load surfaces at the top as a
|
|
16
|
+
// timeout, which the caller can only answer by guessing whether to retry — the
|
|
17
|
+
// single most expensive wrong answer this system can give, because the retry
|
|
18
|
+
// re-runs a scrape that already worked.
|
|
19
|
+
//
|
|
20
|
+
// The extension's half of these numbers cannot be required from here: it runs
|
|
21
|
+
// in Chrome, there is no build step, and MV3 forbids remote code. They are
|
|
22
|
+
// mirrored at the top of background.js with a pointer back to this comment. If
|
|
23
|
+
// you change one, change both.
|
|
24
|
+
|
|
25
|
+
const os = require("os");
|
|
26
|
+
const path = require("path");
|
|
27
|
+
|
|
28
|
+
// Anything the user accumulates lives outside the package. Under a global npm
|
|
29
|
+
// install __dirname resolves inside node_modules/poltertab/, so site memory
|
|
30
|
+
// written next to the code is destroyed by the next `npm update -g` — the
|
|
31
|
+
// upgrade would read as amnesia. Downloads have it worse: output_file exists to
|
|
32
|
+
// keep large payloads out of the context window, and burying them in
|
|
33
|
+
// node_modules makes them hard to find and just as easy to lose.
|
|
34
|
+
//
|
|
35
|
+
// POLTERTAB_HOME exists so the test suite can point this somewhere disposable
|
|
36
|
+
// instead of writing into the real one.
|
|
37
|
+
const POLTERTAB_HOME =
|
|
38
|
+
process.env.POLTERTAB_HOME || path.join(os.homedir(), ".poltertab");
|
|
39
|
+
|
|
40
|
+
// Port: MCP_BROWSER_WS_PORT env, then --port CLI arg, then the default.
|
|
41
|
+
const DEFAULT_WS_PORT = 7822;
|
|
42
|
+
function resolvePort(argv = process.argv, env = process.env) {
|
|
43
|
+
const i = argv.indexOf("--port");
|
|
44
|
+
if (i !== -1 && argv[i + 1]) return parseInt(argv[i + 1], 10);
|
|
45
|
+
if (env.MCP_BROWSER_WS_PORT) return parseInt(env.MCP_BROWSER_WS_PORT, 10);
|
|
46
|
+
return DEFAULT_WS_PORT;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = {
|
|
50
|
+
POLTERTAB_HOME,
|
|
51
|
+
MEMORY_DIR: path.join(POLTERTAB_HOME, "navigation_memory"),
|
|
52
|
+
DOWNLOADS_DIR: path.join(POLTERTAB_HOME, "downloads"),
|
|
53
|
+
|
|
54
|
+
DEFAULT_WS_PORT,
|
|
55
|
+
resolvePort,
|
|
56
|
+
|
|
57
|
+
// See the budget above before changing this.
|
|
58
|
+
COMMAND_TIMEOUT_MS: 35000,
|
|
59
|
+
|
|
60
|
+
// Dead-connection detection. A socket that misses a ping/pong round is
|
|
61
|
+
// terminated so the extension's own reconnect can take over.
|
|
62
|
+
HEARTBEAT_MS: 60000,
|
|
63
|
+
|
|
64
|
+
// The capture buffer is a bounded tail, not a log. Both caps exist to stop a
|
|
65
|
+
// chatty tab from turning the bridge into a memory leak: the server holds
|
|
66
|
+
// response bodies for tabs it may never be asked about again.
|
|
67
|
+
NETWORK_TTL_MS: 5 * 60 * 1000,
|
|
68
|
+
NETWORK_GC_INTERVAL_MS: 60000,
|
|
69
|
+
NETWORK_MAX_REQUESTS: 500,
|
|
70
|
+
NETWORK_MAX_BODY_BYTES: 1024 * 1024,
|
|
71
|
+
|
|
72
|
+
// Promotion race: every orphaned Secondary tries to bind the freed port at
|
|
73
|
+
// once, so they stagger themselves rather than all failing but one.
|
|
74
|
+
PROMOTION_DELAY_MS: 500,
|
|
75
|
+
PROMOTION_JITTER_MS: 2000,
|
|
76
|
+
|
|
77
|
+
// More concurrent agents than this sharing one browser stops being useful and
|
|
78
|
+
// starts being a way to lose track of which tab belongs to whom.
|
|
79
|
+
MAX_SECONDARIES: 5,
|
|
80
|
+
|
|
81
|
+
// Lazy-loaded content arrives after the scroll returns. Long enough for the
|
|
82
|
+
// XHR it triggered to land in the capture buffer, short enough that a
|
|
83
|
+
// scroll-and-read loop is not dominated by waiting.
|
|
84
|
+
SMART_SCROLL_SETTLE_MS: 2000,
|
|
85
|
+
};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// browser_extract_all: paginate a site and return records, so the model stops
|
|
2
|
+
// being the for-loop.
|
|
3
|
+
//
|
|
4
|
+
// sendCommand is injected rather than required, so this module has no opinion
|
|
5
|
+
// about whether it is talking to an extension directly or proxying through a
|
|
6
|
+
// Primary node — and so the suite can drive it with a scripted transport.
|
|
7
|
+
|
|
8
|
+
const { isBlank } = require("./output.js");
|
|
9
|
+
|
|
10
|
+
// --- The pagination loop ---
|
|
11
|
+
//
|
|
12
|
+
// This exists so the model stops being the for-loop. It supplies the spec once
|
|
13
|
+
// and receives records; every page in between costs a browser round-trip
|
|
14
|
+
// instead of a model round-trip.
|
|
15
|
+
//
|
|
16
|
+
// Every halt condition below is a case where continuing would produce data
|
|
17
|
+
// that looks complete and is not, so each one names itself in the result. A
|
|
18
|
+
// paused run costs far less than a confidently wrong dataset.
|
|
19
|
+
async function extractAll(sendCommand, args) {
|
|
20
|
+
const {
|
|
21
|
+
url_template,
|
|
22
|
+
record,
|
|
23
|
+
fields,
|
|
24
|
+
anchor,
|
|
25
|
+
key,
|
|
26
|
+
limit = 200,
|
|
27
|
+
offset = 0,
|
|
28
|
+
start_page = 1,
|
|
29
|
+
max_pages = 50,
|
|
30
|
+
fill_tolerance = 0.5,
|
|
31
|
+
max_text,
|
|
32
|
+
session,
|
|
33
|
+
} = args;
|
|
34
|
+
|
|
35
|
+
if (!url_template.includes("{page}")) {
|
|
36
|
+
throw new Error("url_template must contain a {page} placeholder");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const spec = { record, fields, anchor, max_text, session, probe: true };
|
|
40
|
+
const target = offset + limit;
|
|
41
|
+
const rows = [];
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
const warnings = [];
|
|
44
|
+
const pages = [];
|
|
45
|
+
|
|
46
|
+
let baseline = null;
|
|
47
|
+
let stopped_because = "max_pages";
|
|
48
|
+
let page = start_page;
|
|
49
|
+
let fetched = 0;
|
|
50
|
+
|
|
51
|
+
const keyOf = (row) =>
|
|
52
|
+
key ? String(row[key] ?? "") : JSON.stringify(row);
|
|
53
|
+
|
|
54
|
+
while (fetched < max_pages) {
|
|
55
|
+
const url = url_template.replace("{page}", String(page));
|
|
56
|
+
const nav = await sendCommand("navigate", { url, session });
|
|
57
|
+
fetched++;
|
|
58
|
+
|
|
59
|
+
const res = await sendCommand("extract", spec);
|
|
60
|
+
const pageRows = (res && res.rows) || [];
|
|
61
|
+
pages.push({
|
|
62
|
+
page,
|
|
63
|
+
url: nav && nav.url,
|
|
64
|
+
found: res ? res.records_found : 0,
|
|
65
|
+
kept: pageRows.length,
|
|
66
|
+
dropped: res ? res.dropped : 0,
|
|
67
|
+
});
|
|
68
|
+
if (res && res.warnings && res.warnings.length) {
|
|
69
|
+
warnings.push(`page ${page}: ${res.warnings.join("; ")}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!pageRows.length) {
|
|
73
|
+
stopped_because = "empty_page";
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A site that ignores an unrecognised page param answers every request with
|
|
78
|
+
// page 1. Identical content reads as real data, which is how a "next 100"
|
|
79
|
+
// silently becomes the same 12 records eight times over.
|
|
80
|
+
const fresh = pageRows.filter((r) => !seen.has(keyOf(r)));
|
|
81
|
+
if (!fresh.length) {
|
|
82
|
+
stopped_because = "duplicate_page";
|
|
83
|
+
warnings.push(
|
|
84
|
+
`page ${page} returned only records already seen — pagination is not advancing`,
|
|
85
|
+
);
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Fill rates against the first page. A spec learned on page 1 degrades
|
|
90
|
+
// quietly later: variant card layouts, a column that stops being populated.
|
|
91
|
+
// Halting beats emitting rows that are 40% empty.
|
|
92
|
+
const ratios = {};
|
|
93
|
+
for (const [name, n] of Object.entries(res.fill_rates || {})) {
|
|
94
|
+
ratios[name] = n / pageRows.length;
|
|
95
|
+
}
|
|
96
|
+
if (!baseline) {
|
|
97
|
+
baseline = ratios;
|
|
98
|
+
} else if (fill_tolerance > 0) {
|
|
99
|
+
const collapsed = Object.keys(baseline).filter(
|
|
100
|
+
(name) =>
|
|
101
|
+
baseline[name] >= 0.5 &&
|
|
102
|
+
ratios[name] < baseline[name] * fill_tolerance,
|
|
103
|
+
);
|
|
104
|
+
if (collapsed.length) {
|
|
105
|
+
stopped_because = "fill_rate_deviation";
|
|
106
|
+
warnings.push(
|
|
107
|
+
`page ${page}: ${collapsed
|
|
108
|
+
.map(
|
|
109
|
+
(n) =>
|
|
110
|
+
`${n} ${(ratios[n] * 100).toFixed(0)}% vs baseline ${(baseline[n] * 100).toFixed(0)}%`,
|
|
111
|
+
)
|
|
112
|
+
.join(", ")} — page layout likely differs from the learned spec`,
|
|
113
|
+
);
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const r of fresh) {
|
|
119
|
+
seen.add(keyOf(r));
|
|
120
|
+
rows.push(r);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (rows.length >= target) {
|
|
124
|
+
stopped_because = "limit_reached";
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
page++;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const sliced = rows.slice(offset, offset + limit);
|
|
131
|
+
if (offset && fetched) {
|
|
132
|
+
warnings.push(
|
|
133
|
+
`offset ${offset} was reached by fetching from page ${start_page}; pass start_page to skip pages instead of re-reading them`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// extract reports fill as counts, so extract_all does too — the per-page
|
|
138
|
+
// baseline is a fraction because it is compared across pages of differing
|
|
139
|
+
// size, and carries the unit in its name rather than looking like a count.
|
|
140
|
+
const fill_rates = {};
|
|
141
|
+
for (const name of Object.keys(fields)) {
|
|
142
|
+
fill_rates[name] = sliced.filter((r) => !isBlank(r[name])).length;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
rows: sliced,
|
|
147
|
+
count: sliced.length,
|
|
148
|
+
collected: rows.length,
|
|
149
|
+
pages_fetched: fetched,
|
|
150
|
+
last_page: page,
|
|
151
|
+
stopped_because,
|
|
152
|
+
fill_rates,
|
|
153
|
+
baseline_fill_ratios: baseline,
|
|
154
|
+
pages,
|
|
155
|
+
warnings,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = { extractAll };
|