poltertab 1.3.0 → 1.4.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/README.md CHANGED
@@ -86,10 +86,11 @@ Running from a clone instead of npm? Point the client at the file directly:
86
86
 
87
87
  ## Supported MCP Tools
88
88
 
89
- The server exposes 21 tools to the LLM (all prefixed with `browser_`). Every
89
+ The server exposes 23 tools to the LLM (all prefixed with `browser_`). Every
90
90
  page-facing tool also takes optional `tabId` and `session` to target a specific
91
- tab — omit both and PolterTab uses the last tab it navigated, falling back to
92
- your active tab.
91
+ tab — omit both and PolterTab uses the last tab it navigated. The first
92
+ `browser_navigate` of a session opens its own tab rather than commandeering the
93
+ one you are looking at.
93
94
 
94
95
  ### Page actions
95
96
 
@@ -104,15 +105,77 @@ your active tab.
104
105
 
105
106
  ### Reading the page
106
107
 
108
+ Every tool here takes `output_file`, which writes the payload to
109
+ `~/.poltertab/downloads/` and returns only a summary — row count, field names,
110
+ fill rates and two sample records. Use it for anything bigger than a glance; the
111
+ raw payload should never be the default path into a context window.
112
+
107
113
  | Action | Parameters | Description |
108
114
  | -------- | ----------- | ------------- |
109
- | `browser_scrape` | `selector`, `attribute`, `multiple` | Scrape given elements, or the whole page (title, meta, links, headings, body text) |
110
- | `browser_snapshot` | — | Ref-tagged tree of visible and interactive nodes, capped at 400 |
111
- | `browser_get_text` | `selector` (required) | Text content of one element |
115
+ | `browser_extract` | `record`, `fields` (required), `anchor`, `max_text`, `probe`, `output_file` | Repeating records with fields grouped per record — see below |
116
+ | `browser_extract_all` | `url_template`, `record`, `fields` (required), `key`, `limit`, `offset`, `start_page`, `max_pages`, `fill_tolerance`, `output_file` | Paginate and extract in one call, no model round-trip per page |
117
+ | `browser_scrape` | `selector`, `attribute`, `multiple`, `fields`, `max_text`, `output_file` | Scrape given elements, or the whole page. `attribute` accepts `text` as well as any attribute name; `href`/`src` come back absolute. `fields` picks which parts of a full-page scrape to return — `["meta","jsonld"]` gets og: tags and schema.org blobs without 50KB of body text |
118
+ | `browser_snapshot` | `interactive_only`, `max_nodes`, `max_depth`, `output_file` | Ref-tagged tree of visible and interactive nodes, capped at 400 |
119
+ | `browser_get_text` | `selector` (required), `max_text`, `output_file` | Text content of one element. Flags it in the result when text was cut |
112
120
  | `browser_get_title` | — | Page title and URL |
113
121
  | `browser_get_url` | — | Page URL and title |
114
122
  | `browser_screenshot` | — | Visible tab as base64 PNG (focuses the tab) |
115
123
 
124
+ ### Extracting records
125
+
126
+ `browser_scrape` returns a flat list, which loses track of which value belongs
127
+ to which record. That is fine for one element and dangerous for ninety: the
128
+ moment a field is optional, a flat scrape of names against phone numbers shifts
129
+ every later phone up a row and mis-assigns the rest of the page.
130
+
131
+ `browser_extract` takes a record container and resolves each field *inside* it:
132
+
133
+ ```json
134
+ {
135
+ "record": ".agent-card",
136
+ "fields": {
137
+ "name": { "sel": "a.agent-card-name", "get": "text" },
138
+ "url": { "sel": "a.agent-card-name", "get": "href" },
139
+ "phone": { "sel": "a[href^='tel:']", "get": "href", "strip": "tel:" },
140
+ "socials": { "sel": "a.social-button", "get": "href", "many": true }
141
+ },
142
+ "anchor": "url",
143
+ "output_file": "agents.csv"
144
+ }
145
+ ```
146
+
147
+ - **A missing field yields `null`.** It never shifts.
148
+ - `sel` is relative to the record root; omit it (or use `"."`) for the root itself.
149
+ - `get` is `text` (default), `href`, `src`, or any attribute name.
150
+ - `anchor` names the field a real record always has. Records missing it — the
151
+ placeholder cards that pad the last page of a listing — are dropped and
152
+ counted in `dropped` rather than emitted as rows of nulls.
153
+
154
+ The result reports what happened, so an empty column cannot pass for an answer:
155
+
156
+ ```
157
+ { "count": 12, "dropped": 0,
158
+ "fill_rates": { "name": 12, "phone": 10, "socials": 0 },
159
+ "warnings": ["socials: 0/12 within record scope, but 40 matches page-wide
160
+ for a.social-button — record boundary likely too narrow"] }
161
+ ```
162
+
163
+ That warning is the loosening probe. Any field that comes back entirely empty is
164
+ re-checked page-wide, because the container that *looks* like the record often
165
+ excludes a sibling holding some of the fields — and "0 socials" reads as "these
166
+ people have no social accounts" rather than "your selector scope is wrong".
167
+
168
+ **`browser_extract_all`** takes the same spec plus a `url_template` containing
169
+ `{page}`, and runs the loop itself. It dedups on `key` and halts on the first of:
170
+ `limit_reached`, `empty_page`, `duplicate_page` (a page whose records repeat an
171
+ earlier one — what a site does when it ignores an unrecognised page-size
172
+ param), `fill_rate_deviation` (a page whose fill rates collapse against the
173
+ first page's baseline, which is how a spec learned on page 1 quietly degrades on
174
+ page 10), or `max_pages`. It always reports which condition fired and returns
175
+ everything collected up to that point, so a halt never costs you the run.
176
+
177
+ `.csv` and `.jsonl` output files are written in those formats.
178
+
116
179
  ### Network capture
117
180
 
118
181
  For sites whose DOM is obfuscated or virtualized, read the JSON driving the UI
@@ -271,7 +334,7 @@ Nothing the server accumulates is stored inside the package:
271
334
  ```
272
335
  ~/.poltertab/
273
336
  ├── navigation_memory/ # Per-domain obstacles and fixes (browser_save_site_memory)
274
- └── downloads/ # output_file payloads from browser_get_network_state
337
+ └── downloads/ # output_file payloads from any read tool
275
338
  ```
276
339
 
277
340
  This matters for a global npm install. The package sits in `node_modules/`, so
@@ -654,19 +654,139 @@ const BROWSER_TOOLS = [
654
654
  },
655
655
  {
656
656
  name: "browser_scrape",
657
- description: "Scrape the page or specific elements",
657
+ description:
658
+ "Scrape the page or specific elements. For repeating records (cards, rows, listings) use browser_extract instead — it keeps fields grouped per record.",
658
659
  inputSchema: {
659
660
  type: "object",
660
661
  properties: {
661
662
  selector: { type: "string" },
662
- attribute: { type: "string" },
663
+ attribute: {
664
+ type: "string",
665
+ description:
666
+ "Attribute to read, or 'text' for the element's text. 'href'/'src' come back absolute.",
667
+ },
663
668
  multiple: { type: "boolean" },
669
+ fields: {
670
+ type: "array",
671
+ items: {
672
+ type: "string",
673
+ enum: ["meta", "jsonld", "links", "headings", "bodyText"],
674
+ },
675
+ description:
676
+ "Full-page scrape only (no selector): which parts to return. title and url are always included. ['meta','jsonld'] is the cheap structured-data path — og: tags and schema.org blobs without 50KB of body text. Omit for everything.",
677
+ },
678
+ max_text: {
679
+ type: "number",
680
+ description: "Max characters of text per element (default 500)",
681
+ },
682
+ output_file: {
683
+ type: "string",
684
+ description:
685
+ "Filename to write the payload under ~/.poltertab/downloads/, returning only a summary. A path is reduced to its basename — output cannot be written elsewhere.",
686
+ },
664
687
  tabId: { type: "number" },
665
688
  session: { type: "string" },
666
689
  },
667
690
  required: [],
668
691
  },
669
692
  },
693
+ {
694
+ name: "browser_extract",
695
+ description:
696
+ "Extract repeating records (cards, rows, listings) with fields grouped per record. Fields resolve INSIDE each record root and a missing field yields null instead of shifting later records' values. Returns fill rates and warns when a field is empty inside the record scope but matches page-wide (record boundary too narrow).",
697
+ inputSchema: {
698
+ type: "object",
699
+ properties: {
700
+ record: {
701
+ type: "string",
702
+ description:
703
+ "Selector for the repeating container, e.g. '.agent-card'. Verify with browser_snapshot that it encloses ALL the fields you want — the element that looks like the card often excludes siblings holding some of them.",
704
+ },
705
+ fields: {
706
+ type: "object",
707
+ description:
708
+ "Map of field name -> {sel, get, many, strip}. sel is relative to the record root; omit it (or use '.') for the root itself. get: 'text' (default) | 'href' | 'src' | any attribute name. many: true collects all matches into an array. strip removes a leading prefix, e.g. 'tel:'.",
709
+ additionalProperties: {
710
+ type: "object",
711
+ properties: {
712
+ sel: { type: "string" },
713
+ get: { type: "string" },
714
+ many: { type: "boolean" },
715
+ strip: { type: "string" },
716
+ },
717
+ },
718
+ },
719
+ anchor: {
720
+ type: "string",
721
+ description:
722
+ "Name of the field that is always present on a real record (usually the detail-page link). Records missing it are dropped as placeholders and counted in 'dropped'.",
723
+ },
724
+ max_text: {
725
+ type: "number",
726
+ description: "Max characters per text field (default 500)",
727
+ },
728
+ probe: {
729
+ type: "boolean",
730
+ description:
731
+ "Page-wide re-check of any field that came back entirely empty (default true)",
732
+ },
733
+ output_file: {
734
+ type: "string",
735
+ description:
736
+ "Filename to write rows under ~/.poltertab/downloads/, returning only a summary. .jsonl and .csv are written in those formats, anything else as JSON. A path is reduced to its basename — output cannot be written elsewhere.",
737
+ },
738
+ tabId: { type: "number" },
739
+ session: { type: "string" },
740
+ },
741
+ required: ["record", "fields"],
742
+ },
743
+ },
744
+ {
745
+ name: "browser_extract_all",
746
+ description:
747
+ "Paginate and extract in one call, with no model round-trip per page. Takes browser_extract's spec plus a URL template, walks pages, dedups on a key, and halts on: limit reached, empty page, a page whose records repeat an earlier page's (the trap where ignored page-size params silently return page 1 again), fill rates collapsing against page 1's baseline, or max_pages. Always reports which condition fired and returns everything collected so far.",
748
+ inputSchema: {
749
+ type: "object",
750
+ properties: {
751
+ url_template: {
752
+ type: "string",
753
+ description:
754
+ "URL with a {page} placeholder, e.g. 'https://www.kw.com/agents?page={page}'",
755
+ },
756
+ record: { type: "string" },
757
+ fields: { type: "object" },
758
+ anchor: { type: "string" },
759
+ key: {
760
+ type: "string",
761
+ description:
762
+ "Field name to dedup on — use a stable per-record identifier such as the detail URL. Falls back to whole-row equality.",
763
+ },
764
+ limit: {
765
+ type: "number",
766
+ description: "Stop after this many records (default 200)",
767
+ },
768
+ offset: {
769
+ type: "number",
770
+ description:
771
+ "Skip this many records from the start of the stream. Pages are re-fetched to reach the offset; pass start_page to skip cheaply.",
772
+ },
773
+ start_page: { type: "number", description: "First page (default 1)" },
774
+ max_pages: {
775
+ type: "number",
776
+ description: "Hard guard on pages fetched (default 50)",
777
+ },
778
+ fill_tolerance: {
779
+ type: "number",
780
+ description:
781
+ "Halt when a field well-populated on the baseline page falls below this fraction of it (default 0.5). 0 disables the check.",
782
+ },
783
+ max_text: { type: "number" },
784
+ output_file: { type: "string" },
785
+ session: { type: "string" },
786
+ },
787
+ required: ["url_template", "record", "fields"],
788
+ },
789
+ },
670
790
  {
671
791
  name: "browser_screenshot",
672
792
  description:
@@ -718,6 +838,12 @@ const BROWSER_TOOLS = [
718
838
  type: "object",
719
839
  properties: {
720
840
  selector: { type: "string" },
841
+ max_text: {
842
+ type: "number",
843
+ description:
844
+ "Max characters (default 10000). The result flags it when text was cut.",
845
+ },
846
+ output_file: { type: "string" },
721
847
  tabId: { type: "number" },
722
848
  session: { type: "string" },
723
849
  },
@@ -750,10 +876,21 @@ const BROWSER_TOOLS = [
750
876
  },
751
877
  {
752
878
  name: "browser_snapshot",
753
- description: "Get a snapshot of the DOM",
879
+ description:
880
+ "Get a snapshot of the DOM. Large pages run to tens of KB — narrow it with interactive_only/max_nodes, or send it to output_file, before spending the context on it.",
754
881
  inputSchema: {
755
882
  type: "object",
756
883
  properties: {
884
+ interactive_only: {
885
+ type: "boolean",
886
+ description: "Only clickable/typable elements",
887
+ },
888
+ max_nodes: {
889
+ type: "number",
890
+ description: "Cap on nodes returned (default 400)",
891
+ },
892
+ max_depth: { type: "number" },
893
+ output_file: { type: "string" },
757
894
  tabId: { type: "number" },
758
895
  session: { type: "string" },
759
896
  },
@@ -910,6 +1047,281 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
910
1047
  };
911
1048
  });
912
1049
 
1050
+ // --- Output handling ---
1051
+ //
1052
+ // The raw payload must never be the default path into the context window. Two
1053
+ // costs dominated a 100-record scrape and neither was the data: envelopes
1054
+ // several lines of JSON deep to carry one short string, and the agent then
1055
+ // re-typing every record by hand into a file. Writing straight to disk removes
1056
+ // both.
1057
+
1058
+ function toCsv(rows) {
1059
+ if (!rows.length) return "";
1060
+ const cols = [...new Set(rows.flatMap((r) => Object.keys(r)))];
1061
+ const cell = (v) => {
1062
+ if (v === null || v === undefined) return "";
1063
+ const s = Array.isArray(v)
1064
+ ? v.join(" | ")
1065
+ : typeof v === "object"
1066
+ ? JSON.stringify(v) // beats a column of "[object Object]"
1067
+ : String(v);
1068
+ // Quote only when it would otherwise break the row, and double any
1069
+ // embedded quote — the two mistakes that produce a file which imports
1070
+ // silently and wrongly.
1071
+ return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
1072
+ };
1073
+ return [
1074
+ cols.join(","),
1075
+ ...rows.map((r) => cols.map((c) => cell(r[c])).join(",")),
1076
+ ].join("\n");
1077
+ }
1078
+
1079
+ // Site memory is keyed by hostname, and that key arrives from a model — so it
1080
+ // is untrusted input rather than a filename. Two failures this closes: a note
1081
+ // saved under kw.com was invisible to a lookup for www.kw.com (the same site),
1082
+ // and the raw value was interpolated straight into a path, so "../.." reached
1083
+ // outside MEMORY_DIR.
1084
+ function memoryFile(rawHost) {
1085
+ let host = String(rawHost).trim().toLowerCase();
1086
+
1087
+ // The parameter is also documented as accepting `url`, so a full URL turning
1088
+ // up here is expected rather than a caller mistake.
1089
+ if (host.includes("/")) {
1090
+ try {
1091
+ host = new URL(host.includes("://") ? host : `https://${host}`).hostname;
1092
+ } catch {
1093
+ host = host.split("/")[0];
1094
+ }
1095
+ }
1096
+
1097
+ host = host.replace(/[^a-z0-9.-]/g, "").replace(/^\.+/, "");
1098
+ if (!host) throw new Error(`Not a usable hostname: ${rawHost}`);
1099
+
1100
+ // Existing notes live under whichever spelling first created them — the store
1101
+ // already holds both kw.com.json and www.linkedin.com.json — so try the
1102
+ // variants before concluding this is a new file.
1103
+ const bare = host.replace(/^www\./, "");
1104
+ for (const name of [bare, host, `www.${bare}`]) {
1105
+ const p = path.join(MEMORY_DIR, `${name}.json`);
1106
+ if (fs.existsSync(p)) return p;
1107
+ }
1108
+ return path.join(MEMORY_DIR, `${bare}.json`);
1109
+ }
1110
+
1111
+ const isBlank = (v) =>
1112
+ v === null || v === undefined || v === "" || (Array.isArray(v) && !v.length);
1113
+
1114
+ // `rows` is passed separately when the payload has a records array: .jsonl and
1115
+ // .csv are formats for records, not for envelopes.
1116
+ function writeOutput(name, payload, rows) {
1117
+ const requested = String(name);
1118
+ const safeName = path.basename(requested);
1119
+ const parts = safeName.split(".");
1120
+ const ext = parts.length > 1 ? `.${parts.pop()}` : "";
1121
+ const base = parts.join(".");
1122
+ const finalName = `${base}_${Date.now()}${ext}`;
1123
+
1124
+ fs.mkdirSync(DOWNLOADS_DIR, { recursive: true });
1125
+ const safePath = path.join(DOWNLOADS_DIR, finalName);
1126
+
1127
+ let body;
1128
+ if (rows && ext === ".jsonl") {
1129
+ body = rows.map((r) => JSON.stringify(r)).join("\n");
1130
+ } else if (rows && ext === ".csv") {
1131
+ body = toCsv(rows);
1132
+ } else {
1133
+ body = JSON.stringify(payload, null, 2);
1134
+ }
1135
+
1136
+ fs.writeFileSync(safePath, body);
1137
+ const out = { file: safePath, bytes: Buffer.byteLength(body) };
1138
+
1139
+ // Output stays inside DOWNLOADS_DIR: this path comes from a model, and a tool
1140
+ // that writes to an arbitrary absolute path is a different capability than
1141
+ // one that saves a scrape. But relocating without a word is how a caller ends
1142
+ // up looking for a file that was never going to be there.
1143
+ if (safeName !== requested) {
1144
+ out.note = `output_file is confined to ${DOWNLOADS_DIR} — "${requested}" was written as ${path.basename(safePath)}, not to the path requested.`;
1145
+ }
1146
+ return out;
1147
+ }
1148
+
1149
+ // What comes back inline when the payload went to disk: enough to know the call
1150
+ // worked and the shape is right, and nothing more.
1151
+ function summarizeOutput(payload, rows, written) {
1152
+ const summary = { ...written };
1153
+ if (rows) {
1154
+ summary.rows = rows.length;
1155
+ summary.fields = rows.length ? Object.keys(rows[0]) : [];
1156
+ summary.sample = rows.slice(0, 2);
1157
+ for (const k of ["fill_rates", "dropped", "warnings", "stopped_because", "pages_fetched"]) {
1158
+ if (payload && payload[k] !== undefined) summary[k] = payload[k];
1159
+ }
1160
+ } else if (Array.isArray(payload)) {
1161
+ summary.items = payload.length;
1162
+ summary.sample = payload.slice(0, 2);
1163
+ } else if (payload && typeof payload === "object") {
1164
+ summary.keys = Object.keys(payload);
1165
+ }
1166
+ return summary;
1167
+ }
1168
+
1169
+ // Payload shapes that carry a records array worth writing as jsonl/csv.
1170
+ function rowsOf(payload) {
1171
+ if (payload && Array.isArray(payload.rows)) return payload.rows;
1172
+ if (Array.isArray(payload)) return payload;
1173
+ return null;
1174
+ }
1175
+
1176
+ // --- The pagination loop ---
1177
+ //
1178
+ // This exists so the model stops being the for-loop. It supplies the spec once
1179
+ // and receives records; every page in between costs a browser round-trip
1180
+ // instead of a model round-trip.
1181
+ //
1182
+ // Every halt condition below is a case where continuing would produce data
1183
+ // that looks complete and is not, so each one names itself in the result. A
1184
+ // paused run costs far less than a confidently wrong dataset.
1185
+ async function extractAll(args) {
1186
+ const {
1187
+ url_template,
1188
+ record,
1189
+ fields,
1190
+ anchor,
1191
+ key,
1192
+ limit = 200,
1193
+ offset = 0,
1194
+ start_page = 1,
1195
+ max_pages = 50,
1196
+ fill_tolerance = 0.5,
1197
+ max_text,
1198
+ session,
1199
+ } = args;
1200
+
1201
+ if (!url_template.includes("{page}")) {
1202
+ throw new Error("url_template must contain a {page} placeholder");
1203
+ }
1204
+
1205
+ const spec = { record, fields, anchor, max_text, session, probe: true };
1206
+ const target = offset + limit;
1207
+ const rows = [];
1208
+ const seen = new Set();
1209
+ const warnings = [];
1210
+ const pages = [];
1211
+
1212
+ let baseline = null;
1213
+ let stopped_because = "max_pages";
1214
+ let page = start_page;
1215
+ let fetched = 0;
1216
+
1217
+ const keyOf = (row) =>
1218
+ key ? String(row[key] ?? "") : JSON.stringify(row);
1219
+
1220
+ while (fetched < max_pages) {
1221
+ const url = url_template.replace("{page}", String(page));
1222
+ const nav = await sendCommand("navigate", { url, session });
1223
+ fetched++;
1224
+
1225
+ const res = await sendCommand("extract", spec);
1226
+ const pageRows = (res && res.rows) || [];
1227
+ pages.push({
1228
+ page,
1229
+ url: nav && nav.url,
1230
+ found: res ? res.records_found : 0,
1231
+ kept: pageRows.length,
1232
+ dropped: res ? res.dropped : 0,
1233
+ });
1234
+ if (res && res.warnings && res.warnings.length) {
1235
+ warnings.push(`page ${page}: ${res.warnings.join("; ")}`);
1236
+ }
1237
+
1238
+ if (!pageRows.length) {
1239
+ stopped_because = "empty_page";
1240
+ break;
1241
+ }
1242
+
1243
+ // A site that ignores an unrecognised page param answers every request with
1244
+ // page 1. Identical content reads as real data, which is how a "next 100"
1245
+ // silently becomes the same 12 records eight times over.
1246
+ const fresh = pageRows.filter((r) => !seen.has(keyOf(r)));
1247
+ if (!fresh.length) {
1248
+ stopped_because = "duplicate_page";
1249
+ warnings.push(
1250
+ `page ${page} returned only records already seen — pagination is not advancing`,
1251
+ );
1252
+ break;
1253
+ }
1254
+
1255
+ // Fill rates against the first page. A spec learned on page 1 degrades
1256
+ // quietly later: variant card layouts, a column that stops being populated.
1257
+ // Halting beats emitting rows that are 40% empty.
1258
+ const ratios = {};
1259
+ for (const [name, n] of Object.entries(res.fill_rates || {})) {
1260
+ ratios[name] = n / pageRows.length;
1261
+ }
1262
+ if (!baseline) {
1263
+ baseline = ratios;
1264
+ } else if (fill_tolerance > 0) {
1265
+ const collapsed = Object.keys(baseline).filter(
1266
+ (name) =>
1267
+ baseline[name] >= 0.5 &&
1268
+ ratios[name] < baseline[name] * fill_tolerance,
1269
+ );
1270
+ if (collapsed.length) {
1271
+ stopped_because = "fill_rate_deviation";
1272
+ warnings.push(
1273
+ `page ${page}: ${collapsed
1274
+ .map(
1275
+ (n) =>
1276
+ `${n} ${(ratios[n] * 100).toFixed(0)}% vs baseline ${(baseline[n] * 100).toFixed(0)}%`,
1277
+ )
1278
+ .join(", ")} — page layout likely differs from the learned spec`,
1279
+ );
1280
+ break;
1281
+ }
1282
+ }
1283
+
1284
+ for (const r of fresh) {
1285
+ seen.add(keyOf(r));
1286
+ rows.push(r);
1287
+ }
1288
+
1289
+ if (rows.length >= target) {
1290
+ stopped_because = "limit_reached";
1291
+ break;
1292
+ }
1293
+ page++;
1294
+ }
1295
+
1296
+ const sliced = rows.slice(offset, offset + limit);
1297
+ if (offset && fetched) {
1298
+ warnings.push(
1299
+ `offset ${offset} was reached by fetching from page ${start_page}; pass start_page to skip pages instead of re-reading them`,
1300
+ );
1301
+ }
1302
+
1303
+ // extract reports fill as counts, so extract_all does too — the per-page
1304
+ // baseline is a fraction because it is compared across pages of differing
1305
+ // size, and carries the unit in its name rather than looking like a count.
1306
+ const fill_rates = {};
1307
+ for (const name of Object.keys(fields)) {
1308
+ fill_rates[name] = sliced.filter((r) => !isBlank(r[name])).length;
1309
+ }
1310
+
1311
+ return {
1312
+ rows: sliced,
1313
+ count: sliced.length,
1314
+ collected: rows.length,
1315
+ pages_fetched: fetched,
1316
+ last_page: page,
1317
+ stopped_because,
1318
+ fill_rates,
1319
+ baseline_fill_ratios: baseline,
1320
+ pages,
1321
+ warnings,
1322
+ };
1323
+ }
1324
+
913
1325
  const handleToolCall = async (request) => {
914
1326
  const { name, arguments: args } = request.params;
915
1327
 
@@ -951,24 +1363,12 @@ const handleToolCall = async (request) => {
951
1363
  // payload would flood the very context window this parameter exists to
952
1364
  // protect.
953
1365
  if (opts.output_file) {
954
- // Sanitize the file name to prevent Path Traversal
955
- const safeName = path.basename(opts.output_file);
956
- // Add timestamp to prevent collisions
957
- const parts = safeName.split(".");
958
- const ext = parts.length > 1 ? `.${parts.pop()}` : "";
959
- const base = parts.join(".");
960
- const finalName = `${base}_${Date.now()}${ext}`;
961
-
962
- fs.mkdirSync(DOWNLOADS_DIR, { recursive: true });
963
-
964
- const safePath = path.join(DOWNLOADS_DIR, finalName);
965
- fs.writeFileSync(safePath, JSON.stringify(responsePayload, null, 2));
966
-
1366
+ const written = writeOutput(opts.output_file, responsePayload);
967
1367
  return {
968
1368
  content: [
969
1369
  {
970
1370
  type: "text",
971
- text: `Data successfully written to ${safePath}. Captured ${responsePayload.capturedRequests} requests.`,
1371
+ text: `Data successfully written to ${written.file}. Captured ${responsePayload.capturedRequests} requests.`,
972
1372
  },
973
1373
  ],
974
1374
  };
@@ -1029,7 +1429,7 @@ const handleToolCall = async (request) => {
1029
1429
  if (action === "get_site_memory") {
1030
1430
  const host = args.hostname || args.domain || args.url;
1031
1431
  if (!host) throw new Error("Missing 'hostname' parameter");
1032
- const file = path.join(MEMORY_DIR, `${host}.json`);
1432
+ const file = memoryFile(host);
1033
1433
  let data = [];
1034
1434
  if (fs.existsSync(file)) {
1035
1435
  data = JSON.parse(fs.readFileSync(file, "utf8"));
@@ -1042,7 +1442,7 @@ const handleToolCall = async (request) => {
1042
1442
  if (action === "save_site_memory") {
1043
1443
  const host = args.hostname || args.domain || args.url;
1044
1444
  if (!host) throw new Error("Missing 'hostname' parameter");
1045
- const file = path.join(MEMORY_DIR, `${host}.json`);
1445
+ const file = memoryFile(host);
1046
1446
  let data = [];
1047
1447
  if (fs.existsSync(file)) {
1048
1448
  data = JSON.parse(fs.readFileSync(file, "utf8"));
@@ -1058,6 +1458,32 @@ const handleToolCall = async (request) => {
1058
1458
  };
1059
1459
  }
1060
1460
 
1461
+ // Loops in the server, not in the model. One tool call covers every page.
1462
+ if (action === "extract_all") {
1463
+ const payload = await extractAll(args || {});
1464
+ const opts = args || {};
1465
+
1466
+ if (opts.output_file) {
1467
+ const written = writeOutput(opts.output_file, payload, payload.rows);
1468
+ const { rows, pages, ...rest } = payload;
1469
+ const summary = {
1470
+ ...rest,
1471
+ ...written,
1472
+ fields: rows.length ? Object.keys(rows[0]) : [],
1473
+ sample: rows.slice(0, 2),
1474
+ };
1475
+ return {
1476
+ content: [
1477
+ { type: "text", text: JSON.stringify(summary, null, 2) },
1478
+ ],
1479
+ };
1480
+ }
1481
+
1482
+ return {
1483
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
1484
+ };
1485
+ }
1486
+
1061
1487
  const result = await sendCommand(action, args || {});
1062
1488
 
1063
1489
  if (!isSecondary) {
@@ -1071,6 +1497,25 @@ const handleToolCall = async (request) => {
1071
1497
  }
1072
1498
  }
1073
1499
 
1500
+ // Any read tool can send its payload to disk. Placed after tab tracking so
1501
+ // taking the file path does not cost the session its tab bookkeeping.
1502
+ if (args && args.output_file && result && typeof result === "object") {
1503
+ const rows = rowsOf(result);
1504
+ const written = writeOutput(args.output_file, result, rows);
1505
+ return {
1506
+ content: [
1507
+ {
1508
+ type: "text",
1509
+ text: JSON.stringify(
1510
+ summarizeOutput(result, rows, written),
1511
+ null,
1512
+ 2,
1513
+ ),
1514
+ },
1515
+ ],
1516
+ };
1517
+ }
1518
+
1074
1519
  // Check if error result string (graceful error handling)
1075
1520
  if (
1076
1521
  typeof result === "string" &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poltertab",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Let any AI assistant drive and scrape your existing Chrome profile — no headless browser, no complex setup.",
5
5
  "bin": {
6
6
  "poltertab": "./bin/poltertab.js"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: browser-navigation-strategy
3
- description: Use when driving a real browser with PolterTab's browser_* tools — navigating, scraping, or clicking through a site whose DOM is virtualized, obfuscated, shadow-rooted, or iframe-sandboxed, and selectors keep going stale.
3
+ description: Use when driving a real browser with PolterTab's browser_* tools — extracting repeating records, paginating a listing, navigating, scraping, or clicking through a site whose DOM is virtualized, obfuscated, shadow-rooted, or iframe-sandboxed, and selectors keep going stale.
4
4
  ---
5
5
 
6
6
  # Browser Navigation Strategy
@@ -9,49 +9,140 @@ PolterTab drives the user's real Chrome profile. Sessions are already logged in,
9
9
  and every action happens in a window the user can see. That makes it powerful and
10
10
  unforgiving: a wrong click lands on a real account.
11
11
 
12
- ## Read the page before you touch it
12
+ ## Pick the right tool before you start
13
13
 
14
- `browser_snapshot` is the first call on any unfamiliar page. It returns visible,
15
- interactive elements with `@e1`-style refs, and it descends into shadow roots and
16
- child frames — so what it shows you is the page as it really is, not just the
17
- top-level light DOM.
14
+ | What you want | Use |
15
+ | --- | --- |
16
+ | Many similar records on a page (cards, rows, listings) | `browser_extract` |
17
+ | Those records across many pages | `browser_extract_all` |
18
+ | One element's text | `browser_get_text` |
19
+ | Page title, og: tags, schema.org data | `browser_scrape` with `fields` |
20
+ | To find out what a page contains | `browser_snapshot` |
21
+ | Data the DOM does not hold (virtualized/obfuscated) | intercept + `browser_get_network_state` |
18
22
 
19
- Never guess a selector from a URL or a screenshot. Snapshot, then act on what
20
- came back.
23
+ Getting this wrong is the most expensive mistake available. Scraping 100 records
24
+ one field at a time costs a round-trip per page and leaves you reassembling
25
+ records by hand; `browser_extract_all` is one call.
21
26
 
22
- ## Prefer the network over the DOM
27
+ ## Repeating records: use browser_extract
23
28
 
24
- On a virtualized list (LinkedIn, most infinite feeds), the DOM holds only the
25
- rows currently on screen, under class names that change between deploys. Scraping
26
- it yields a fraction of the data and breaks next week.
29
+ A flat scrape returns a list per selector, so you have to work out which value
30
+ belongs to which record. That works until a field is optional — then one missing
31
+ value shifts every later record and the result looks completely plausible.
32
+
33
+ `browser_extract` takes a container and resolves each field *inside* it:
34
+
35
+ ```json
36
+ {
37
+ "record": ".agent-card",
38
+ "fields": {
39
+ "name": { "sel": "a.agent-card-name", "get": "text" },
40
+ "url": { "sel": "a.agent-card-name", "get": "href" },
41
+ "phone": { "sel": "a[href^='tel:']", "get": "href", "strip": "tel:" },
42
+ "socials": { "sel": "a.social-button", "get": "href", "many": true }
43
+ },
44
+ "anchor": "url",
45
+ "output_file": "agents.csv"
46
+ }
47
+ ```
48
+
49
+ - A missing field is `null`. It never shifts.
50
+ - `sel` is relative to the record; omit it (or `"."`) for the record itself.
51
+ - `get`: `text` (default), `href`, `src`, or any attribute name. `href`/`src`
52
+ come back absolute.
53
+ - `anchor` names the field every real record has. Records without it — the
54
+ placeholder cards padding a last page — are dropped and counted.
55
+
56
+ **Read the response before trusting it.** `fill_rates` and `warnings` tell you
57
+ whether the spec is right:
58
+
59
+ ```
60
+ fill_rates: { name: 12, phone: 10, socials: 0 }
61
+ warnings: ["socials: 0/12 within record scope, but 40 matches page-wide
62
+ for a.social-button — record boundary likely too narrow"]
63
+ ```
64
+
65
+ That warning means the container you picked excludes a sibling holding the
66
+ field. Widen `record` and re-run. Without it, an empty column reads as "these
67
+ records have no socials" — confidently wrong.
68
+
69
+ A field at 0 with *no* page-wide matches means the selector is wrong, not the
70
+ boundary. The message distinguishes the two; act on which one you got.
71
+
72
+ ## Paginating: use browser_extract_all
73
+
74
+ Same spec plus a `url_template` containing `{page}`. It runs the loop itself, so
75
+ pages cost a browser round-trip instead of a model round-trip.
76
+
77
+ Dedup on `key` — a stable per-record id, the detail URL or an element `id`, never
78
+ position. It halts on the first of `limit_reached`, `empty_page`,
79
+ `duplicate_page`, `fill_rate_deviation`, or `max_pages`, and always says which.
80
+
81
+ Two of those are worth understanding:
82
+
83
+ - **`duplicate_page`** — the page returned records already seen. This is what a
84
+ site does with a param it does not recognise: it serves page 1 again. Without
85
+ the check you would collect the same 12 records eight times.
86
+ - **`fill_rate_deviation`** — a field well-populated on page 1 collapsed on a
87
+ later page, so the learned spec no longer fits. Everything collected so far is
88
+ returned; re-run from `start_page` with a spec covering both layouts. Set
89
+ `fill_tolerance: 0` if a site is legitimately heterogeneous.
90
+
91
+ ## Take the free structured data first
27
92
 
28
- The reliable path:
93
+ Before writing any selector, try:
94
+
95
+ ```json
96
+ { "fields": ["meta", "jsonld"] }
97
+ ```
98
+
99
+ on `browser_scrape`. `title`, every `og:`/`twitter:` tag, and any schema.org
100
+ `ld+json` blob come back with no selectors at all. Real estate, jobs, products,
101
+ events and profile pages very often ship a complete typed record this way —
102
+ name, telephone, email, postal address, employer — which beats any selector you
103
+ could write and does not break on redesign.
104
+
105
+ This is the cheapest call available on a detail page. Try it before building an
106
+ enrichment scrape.
107
+
108
+ ## Prefer the network over the DOM — when the DOM is the problem
109
+
110
+ On a virtualized list (LinkedIn, most infinite feeds), the DOM holds only the
111
+ rows on screen, under class names that change between deploys. There, the
112
+ network is the reliable path:
29
113
 
30
114
  1. `browser_set_intercept_patterns` if the site uses endpoints outside the
31
115
  defaults (`graphql`, `/api/`, `voyager`, `feed`).
32
116
  2. `browser_smart_scroll` to trigger lazy loading. It yields until new requests
33
117
  land, so you do not have to guess a wait.
34
- 3. `browser_get_network_state` to read the structured JSON the page itself
35
- received.
118
+ 3. `browser_get_network_state` to read the JSON the page itself received.
119
+
120
+ **But check first that there is a network path at all.** Plenty of sites are
121
+ server-rendered: the records ship in the initial HTML and no XHR ever fires.
122
+ Chasing the network there costs several calls and returns an empty buffer that
123
+ looks identical to "you set the wrong pattern". If `browser_extract` already
124
+ returns full records, or `fields: ["jsonld"]` has the data, the page is
125
+ server-rendered — use the DOM and move on.
36
126
 
37
- Pass `output_file` on `browser_get_network_state` for anything large. The payload
38
- goes to disk and you get a summary — a 4 MB GraphQL response does not belong in
39
- the context window.
127
+ The default intercept patterns include `/api/`, which matches analytics and maps
128
+ traffic on most sites. An almost-empty-looking buffer full of Google requests is
129
+ that, not your data.
40
130
 
41
131
  ## Shadow DOM, iframes, and late modals
42
132
 
43
133
  PolterTab handles these without special flags, which is worth knowing so you do
44
134
  not work around problems it already solves:
45
135
 
46
- - **Shadow roots** — including closed ones — are pierced automatically. A
47
- selector that matches inside a web component just works.
136
+ - **Shadow roots** — including closed ones — are pierced automatically, in
137
+ `browser_extract`'s record scope too.
48
138
  - **Child frames** are searched in parallel when the top frame misses, so an
49
139
  element inside a sandboxed iframe is reachable by plain selector.
50
140
  - **Late-rendering elements** get retried for ~3s. A modal that mounts a beat
51
141
  after the click that opened it does not need a manual wait.
52
142
 
53
- If an element genuinely is not there, you get `Element not found` after the
54
- retry window. Treat that as "not on this page", not "try again immediately".
143
+ `Element not found` after that window means "not on this page", not "try again".
144
+ `browser_extract` does not throw for this — it returns `records_found: 0` with
145
+ the warnings above, which tell you more than an error would.
55
146
 
56
147
  ## Selectors, in order of durability
57
148
 
@@ -63,32 +154,69 @@ retry window. Treat that as "not on this page", not "try again immediately".
63
154
  Long generated class chains (`.css-1x2y3z4`) are the worst of both worlds:
64
155
  brittle and unreadable. Reach for a `@e` ref from the last snapshot instead.
65
156
 
157
+ ## Keep payloads out of the context window
158
+
159
+ Every read tool takes `output_file`. The payload goes to
160
+ `~/.poltertab/downloads/` and you get back row count, field names, fill rates
161
+ and two sample records. Use it for anything bigger than a glance.
162
+
163
+ `.csv` and `.jsonl` are written in those formats, so the file is the deliverable
164
+ — never re-type extracted records into a file by hand, which costs more than the
165
+ extraction did.
166
+
167
+ `browser_snapshot` on a real page runs to tens of KB. Narrow it with
168
+ `interactive_only` or `max_nodes`, or send it to `output_file`.
169
+
170
+ Text fields are cut at `max_text` (default 500) and the result says when it
171
+ happened. Raise it rather than assuming you have the whole value.
172
+
173
+ ## Known limits — do not fight these
174
+
175
+ - **Records spanning sibling elements.** `browser_extract` needs one container
176
+ per record. A table where an item's fields live in two sibling `<tr>`s (Hacker
177
+ News is the canonical case) has no such container. Extract twice and join on a
178
+ shared id — never on position.
179
+ - **Pagination that is not a URL parameter.** `browser_extract_all` only drives
180
+ `{page}` in a URL. Infinite scroll, a "Load more" button, and cursor tokens
181
+ need `browser_smart_scroll` or clicking, in a loop you run yourself.
182
+ - **Virtualized lists.** `browser_extract` reads the DOM, so it sees only the
183
+ rendered window. Use the network path.
184
+ - **`output_file` writes only to `~/.poltertab/downloads/`.** A path is reduced
185
+ to its basename; the returned `file` is where it actually went.
186
+
66
187
  ## Carry knowledge between sessions
67
188
 
68
189
  `browser_get_site_memory(hostname)` at the start of work on a site, and
69
- `browser_save_site_memory` the moment you solve something non-obvious — a cookie
70
- wall that intercepts the first click, a required query parameter, the shape of a
71
- GraphQL response worth reusing.
190
+ `browser_save_site_memory` the moment you solve something non-obvious. Save the
191
+ record root, the field spec that worked, the pagination parameter, and any
192
+ obstacle with its fix. `www.` is normalized and a full URL is accepted, so
193
+ lookups do not miss.
72
194
 
73
- The point is to not rediscover the same obstacle next week. Save the obstacle and
74
- the fix together; a note saying "this page is tricky" helps nobody.
195
+ A note saying "this page is tricky" helps nobody. Save what you would need to
196
+ skip the discovery entirely next time.
75
197
 
76
198
  ## Multiple tabs
77
199
 
78
200
  `browser_session_create` gives a named tab you can return to. Every page-facing
79
- tool takes `session` (or `tabId`) to target it. Omit both and PolterTab uses the
80
- last tab it navigated, falling back to the active tab — fine for single-track
81
- work, ambiguous the moment two flows interleave. Name your sessions when running
82
- more than one.
201
+ tool takes `session` (or `tabId`).
83
202
 
84
- Close what you opened with `browser_session_close`.
203
+ Omit both and the first `browser_navigate` opens its own tab — it does not
204
+ commandeer the tab the user is looking at — and later calls reuse it. That is
205
+ fine for single-track work and ambiguous the moment two flows interleave, so
206
+ name your sessions when running more than one. Close what you opened with
207
+ `browser_session_close`.
85
208
 
86
209
  ## Pitfalls
87
210
 
211
+ - Do not scrape repeating records field-by-field. Use `browser_extract`, and
212
+ read its `warnings`.
213
+ - Do not zip flat arrays into records by position. One optional field silently
214
+ ruins the whole dataset.
215
+ - Do not loop pages yourself when `url_template` covers the site.
88
216
  - Do not use `browser_get_text` or `browser_scrape` on virtualized lists. Use
89
217
  `browser_smart_scroll` then `browser_get_network_state`.
90
- - Do not invent intercept patterns. Check the defaults, then set only what the
91
- site actually needs.
218
+ - Do not invent intercept patterns, and do not assume an empty buffer means you
219
+ asked wrong — the site may be server-rendered.
92
220
  - Do not paste large scraped payloads into the conversation. Use `output_file`.
93
221
  - Do not retry a failed selector unchanged. The retry already happened inside
94
222
  the tool — snapshot again and find out what the page really contains.