poltertab 1.2.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 +70 -7
- package/mcp-server/index.js +464 -19
- package/package.json +1 -1
- package/skills/browser-navigation-strategy/SKILL.md +162 -34
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
|
|
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
|
|
92
|
-
|
|
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
|
-
| `
|
|
110
|
-
| `
|
|
111
|
-
| `
|
|
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
|
|
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
|
package/mcp-server/index.js
CHANGED
|
@@ -654,19 +654,139 @@ const BROWSER_TOOLS = [
|
|
|
654
654
|
},
|
|
655
655
|
{
|
|
656
656
|
name: "browser_scrape",
|
|
657
|
-
description:
|
|
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: {
|
|
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:
|
|
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
|
-
|
|
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 ${
|
|
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 =
|
|
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 =
|
|
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: 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
|
-
##
|
|
12
|
+
## Pick the right tool before you start
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
##
|
|
27
|
+
## Repeating records: use browser_extract
|
|
23
28
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
47
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
74
|
-
the
|
|
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`)
|
|
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
|
-
|
|
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
|
|
91
|
-
site
|
|
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.
|