poltertab 1.3.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.
@@ -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 };