@trawlme/cli 3.9.0 → 3.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/doctor.d.ts +25 -7
- package/dist/commands/doctor.js +129 -30
- package/dist/commands/scraps.js +6 -2
- package/package.json +1 -1
|
@@ -25,19 +25,31 @@ export interface Run {
|
|
|
25
25
|
selectors?: Record<string, number>;
|
|
26
26
|
} | null;
|
|
27
27
|
blocked?: boolean;
|
|
28
|
+
block?: {
|
|
29
|
+
kind?: string | null;
|
|
30
|
+
} | null;
|
|
31
|
+
/** @deprecated trawl_node#1950 renamed this to `block.kind`. Kept as a read
|
|
32
|
+
* fallback (see detectWallVendor) for the window where this CLI is
|
|
33
|
+
* published ahead of the trawl_node prod tag — a prod backend served from
|
|
34
|
+
* the pre-#1950 tag still returns this flat field, not `block.kind`. Drop
|
|
35
|
+
* once prod is confirmed on a tag containing #1950. */
|
|
28
36
|
blockType?: string | null;
|
|
29
37
|
proxyTier?: string | null;
|
|
30
|
-
regressionDetected?: boolean;
|
|
31
38
|
baselineLength?: number | null;
|
|
32
39
|
fixVersionId?: string | null;
|
|
33
40
|
createdAt?: string;
|
|
34
41
|
time?: number | null;
|
|
35
42
|
triggeredBy?: string | null;
|
|
43
|
+
/** trawl_node#1975 — freeform failure classification (`'auth'` = login-wall
|
|
44
|
+
* empty run, cookies are the fix). Not a TS union — trawl_node's own set
|
|
45
|
+
* is additive/open, so this CLI must stay read-safe against a future
|
|
46
|
+
* value it doesn't know about yet, same posture as `block.kind` above. */
|
|
47
|
+
failureKind?: string | null;
|
|
36
48
|
}
|
|
37
49
|
/**
|
|
38
|
-
* Resolve a known anti-bot vendor
|
|
39
|
-
* string. Returns null when the run succeeded, when there is no `
|
|
40
|
-
* signal at all, or when `
|
|
50
|
+
* Resolve a known anti-bot vendor name from a worker `block.kind`
|
|
51
|
+
* string. Returns null when the run succeeded, when there is no `block.kind`
|
|
52
|
+
* signal at all, or when `block.kind` names something other than a known
|
|
41
53
|
* vendor (e.g. `proxy-domain-gate`, `rate_limited_per_host`) — those stay on
|
|
42
54
|
* the genuine-error path since we can't honestly attribute them to a specific
|
|
43
55
|
* "no reliable bypass" wall.
|
|
@@ -45,12 +57,18 @@ export interface Run {
|
|
|
45
57
|
* `blocked` is deliberately NOT consulted here (#177): it is a mid-run,
|
|
46
58
|
* attempt-level signal the worker only stamps on one envelope shape, so it
|
|
47
59
|
* reads `false` on the great majority of genuinely walled runs (the early-block
|
|
48
|
-
* throw path sets `
|
|
60
|
+
* throw path sets `block.kind` but never `blocked` — 63 of 64 walled runs
|
|
49
61
|
* measured on prod). `status === true` wins instead: a run that ultimately
|
|
50
|
-
* returned data was not walled, even if an earlier tier's `
|
|
62
|
+
* returned data was not walled, even if an earlier tier's `block.kind` stamp
|
|
51
63
|
* survived on the row.
|
|
64
|
+
*
|
|
65
|
+
* trawl_node#1950 renamed the flat `blockType` field to nested `block.kind`.
|
|
66
|
+
* Read `block?.kind` first, falling back to the deprecated flat `blockType` —
|
|
67
|
+
* this CLI can be published (and talk to prod) before the trawl_node prod tag
|
|
68
|
+
* containing #1950 is cut (see the `Run.blockType` doc comment), so a prod
|
|
69
|
+
* response can still be the pre-#1950 flat shape for a while.
|
|
52
70
|
*/
|
|
53
|
-
export declare function detectWallVendor(run: Pick<Run, 'status' | 'statusDetail' | 'blockType'>): string | null;
|
|
71
|
+
export declare function detectWallVendor(run: Pick<Run, 'status' | 'statusDetail' | 'block' | 'blockType'>): string | null;
|
|
54
72
|
/**
|
|
55
73
|
* Autofix activity metadata — from the persisted ai_fix_end activity.
|
|
56
74
|
* aiUsage (cost) is stripped server-side; all diagnostics are kept.
|
package/dist/commands/doctor.js
CHANGED
|
@@ -2,23 +2,25 @@ import { api } from '../lib/api.js';
|
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import { formatDate } from '../lib/format.js';
|
|
4
4
|
/**
|
|
5
|
-
* Known anti-bot vendors
|
|
6
|
-
* Ordered by first-match;
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Known anti-bot vendors that the worker's `block.kind` field may name
|
|
6
|
+
* (ex-flat `blockType`, trawl_node#1950). Ordered by first-match;
|
|
7
|
+
* `block.kind` is a freeform worker string, not an enum (see the `Run.block`
|
|
8
|
+
* doc comment), so this is a best-effort substring match against the real
|
|
9
|
+
* field — never an invented/mocked value.
|
|
9
10
|
*
|
|
10
11
|
* `datadome`/`perimeterx`/`akamai`/`cloudflare` are literal substrings the
|
|
11
|
-
* current worker emits (detect.js). `kasada`
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* automatically if/when a future worker classifier does, without a CLI
|
|
12
|
+
* current worker emits (detect.js). `kasada` is forward-compatible: the issue
|
|
13
|
+
* (#62 / wall-registry WS-7) names it as a genuine wall, but the current
|
|
14
|
+
* worker vocabulary does not yet emit it — this branch lights up
|
|
15
|
+
* automatically if/when a future worker classifier does, without a CLI
|
|
16
|
+
* change.
|
|
15
17
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
18
|
+
* trawl_cli#182 — an `auth` entry used to live here, rendering a login wall
|
|
19
|
+
* as "walled: auth — no reliable bypass". Removed: unlike a real anti-bot
|
|
20
|
+
* vendor, a login wall's own reliable bypass is the user's session cookies,
|
|
21
|
+
* so that verdict was wrong, not just early. `failureKind==='auth'`
|
|
22
|
+
* (trawl_node#1975) is the honest signal for a login wall — see the
|
|
23
|
+
* dedicated branch in `formatDoctor` below.
|
|
22
24
|
*/
|
|
23
25
|
const WALL_VENDOR_PATTERNS = [
|
|
24
26
|
[/datadome/i, 'DataDome'],
|
|
@@ -26,12 +28,11 @@ const WALL_VENDOR_PATTERNS = [
|
|
|
26
28
|
[/perimeterx/i, 'PerimeterX'],
|
|
27
29
|
[/akamai/i, 'Akamai'],
|
|
28
30
|
[/cloudflare/i, 'Cloudflare'],
|
|
29
|
-
[/(?:^|[-_:])auth(?:$|[-_:])/i, 'auth'],
|
|
30
31
|
];
|
|
31
32
|
/**
|
|
32
|
-
* Resolve a known anti-bot vendor
|
|
33
|
-
* string. Returns null when the run succeeded, when there is no `
|
|
34
|
-
* signal at all, or when `
|
|
33
|
+
* Resolve a known anti-bot vendor name from a worker `block.kind`
|
|
34
|
+
* string. Returns null when the run succeeded, when there is no `block.kind`
|
|
35
|
+
* signal at all, or when `block.kind` names something other than a known
|
|
35
36
|
* vendor (e.g. `proxy-domain-gate`, `rate_limited_per_host`) — those stay on
|
|
36
37
|
* the genuine-error path since we can't honestly attribute them to a specific
|
|
37
38
|
* "no reliable bypass" wall.
|
|
@@ -39,24 +40,31 @@ const WALL_VENDOR_PATTERNS = [
|
|
|
39
40
|
* `blocked` is deliberately NOT consulted here (#177): it is a mid-run,
|
|
40
41
|
* attempt-level signal the worker only stamps on one envelope shape, so it
|
|
41
42
|
* reads `false` on the great majority of genuinely walled runs (the early-block
|
|
42
|
-
* throw path sets `
|
|
43
|
+
* throw path sets `block.kind` but never `blocked` — 63 of 64 walled runs
|
|
43
44
|
* measured on prod). `status === true` wins instead: a run that ultimately
|
|
44
|
-
* returned data was not walled, even if an earlier tier's `
|
|
45
|
+
* returned data was not walled, even if an earlier tier's `block.kind` stamp
|
|
45
46
|
* survived on the row.
|
|
47
|
+
*
|
|
48
|
+
* trawl_node#1950 renamed the flat `blockType` field to nested `block.kind`.
|
|
49
|
+
* Read `block?.kind` first, falling back to the deprecated flat `blockType` —
|
|
50
|
+
* this CLI can be published (and talk to prod) before the trawl_node prod tag
|
|
51
|
+
* containing #1950 is cut (see the `Run.blockType` doc comment), so a prod
|
|
52
|
+
* response can still be the pre-#1950 flat shape for a while.
|
|
46
53
|
*/
|
|
47
54
|
export function detectWallVendor(run) {
|
|
48
55
|
// A run that returned data was not walled. `status === true` is not the whole
|
|
49
56
|
// predicate: trawl_node flips a degraded-but-non-empty run to
|
|
50
57
|
// `status:false, statusDetail:'regression'` (scraps.service.js, #1112) via a
|
|
51
|
-
// patch that never clears `
|
|
58
|
+
// patch that never clears `block.kind` — so keying on `status` alone would
|
|
52
59
|
// print "no reliable bypass" next to "Regression: length N vs baseline M",
|
|
53
60
|
// which is the same contradiction this guard exists to remove.
|
|
54
61
|
if (run.status === true || run.statusDetail === 'regression')
|
|
55
62
|
return null;
|
|
56
|
-
|
|
63
|
+
const kind = run.block?.kind ?? run.blockType ?? null;
|
|
64
|
+
if (!kind)
|
|
57
65
|
return null; // no signal at all
|
|
58
66
|
for (const [pattern, label] of WALL_VENDOR_PATTERNS) {
|
|
59
|
-
if (pattern.test(
|
|
67
|
+
if (pattern.test(kind))
|
|
60
68
|
return label;
|
|
61
69
|
}
|
|
62
70
|
return null;
|
|
@@ -70,8 +78,8 @@ const TIER_LABELS = {
|
|
|
70
78
|
};
|
|
71
79
|
const RUN_ALLOWLIST = [
|
|
72
80
|
'_id', 'status', 'statusDetail', 'length', 'errorMessage', 'errorSnapshot',
|
|
73
|
-
'emptyContext', 'blocked', '
|
|
74
|
-
'fixVersionId', 'createdAt', 'time', 'triggeredBy',
|
|
81
|
+
'emptyContext', 'blocked', 'block', 'blockType', 'proxyTier', 'baselineLength',
|
|
82
|
+
'fixVersionId', 'createdAt', 'time', 'triggeredBy', 'failureKind',
|
|
75
83
|
];
|
|
76
84
|
const FIX_ALLOWLIST = [
|
|
77
85
|
'outcome', 'classification', 'reason', 'fixDiff', 'dryRunResults',
|
|
@@ -124,14 +132,81 @@ export function formatDoctor(scrapTitle, run, fix = null, scrapId) {
|
|
|
124
132
|
lines.push(`${chalk.bold(scrapTitle)} ${badge}${run.statusDetail ? ` (${run.statusDetail})` : ''}`);
|
|
125
133
|
lines.push(chalk.dim(` Run ID: ${run._id}`));
|
|
126
134
|
// Error message — an honest accept-wall string for known-walled scraps
|
|
127
|
-
// (DataDome/Kasada/PerimeterX/Akamai
|
|
135
|
+
// (DataDome/Kasada/PerimeterX/Akamai terminal verdict from the worker),
|
|
128
136
|
// otherwise the real error (genuine transient failure).
|
|
137
|
+
//
|
|
138
|
+
// trawl_cli#182 — the wall-vendor verdict (this CLI's own
|
|
139
|
+
// WALL_VENDOR_PATTERNS matched against block.kind/blockType) and the
|
|
140
|
+
// login-wall hint (node's failureKind==='auth', trawl_node#1975) are two
|
|
141
|
+
// INDEPENDENT server-derived signals with no shared contract: node's block
|
|
142
|
+
// classifier (modules/historys/helpers/failureKind.js BLOCK_TYPE_PATTERN)
|
|
143
|
+
// and this CLI's vendor table live in different repos and can legitimately
|
|
144
|
+
// disagree. Concretely, `kasada` matches this CLI's table but is absent
|
|
145
|
+
// from node's block pattern, and the deprecated flat `blockType` fallback
|
|
146
|
+
// is a field node's classifier no longer reads at all — so a run can come
|
|
147
|
+
// back `failureKind:'auth'` (the server says login wall) while also
|
|
148
|
+
// matching a vendor here (this CLI says Kasada/DataDome/etc), both true
|
|
149
|
+
// signals about the same run, neither one wrong.
|
|
150
|
+
//
|
|
151
|
+
// trawl_cli#169 precedent: never assert client-side what only the server
|
|
152
|
+
// knows. Silently picking a winner between two disagreeing signals is
|
|
153
|
+
// exactly that — printing "no reliable bypass" alone asserts the run is
|
|
154
|
+
// NOT an auth wall (may stop someone from trying a fixable cookie
|
|
155
|
+
// problem); printing the cookie hint alone asserts the run is NOT a
|
|
156
|
+
// vendor wall (may send someone chasing cookies against a wall no cookie
|
|
157
|
+
// fixes). This CLI cannot tell which is true without either importing
|
|
158
|
+
// node's BLOCK_TYPE_PATTERN (a second source of truth for the same list —
|
|
159
|
+
// how this defect got here) or re-detecting the login URL itself (the
|
|
160
|
+
// client-side re-derivation this fix deliberately rejects). So when both
|
|
161
|
+
// fire, render ONE hedged line naming both readings instead of picking:
|
|
162
|
+
// never emit the bare "no reliable bypass" verdict in that case — it is
|
|
163
|
+
// exactly the false precision this branch exists to avoid.
|
|
164
|
+
//
|
|
165
|
+
// `conflictingSignals` is deliberately NOT gated on `scrapId` the way the
|
|
166
|
+
// plain hint below is: the false "no reliable bypass" verdict is wrong
|
|
167
|
+
// regardless of whether we also have an id to build an actionable command
|
|
168
|
+
// for, so a scrapId-less conflicting run must still avoid it. Only the
|
|
169
|
+
// action line degrades (to a generic Settings pointer) when there's no id.
|
|
170
|
+
//
|
|
171
|
+
// Single-signal cases are untouched: a plain vendor match (no
|
|
172
|
+
// failureKind:'auth') still gets "no reliable bypass"; a plain
|
|
173
|
+
// failureKind:'auth' (no vendor match) still gets the plain hint block
|
|
174
|
+
// below. `errMsg` stays suppressed whenever any hint form (conflicting or
|
|
175
|
+
// plain) is about to render — node's own `errorMessage` for an auth run
|
|
176
|
+
// (historys.service.js ~line 774) IS the hint copy verbatim, literal
|
|
177
|
+
// `<id>` placeholder and all, so printing it here would just be a second
|
|
178
|
+
// rendering of the same information. If neither hint form renders (no
|
|
179
|
+
// vendor conflict and no scrapId for the plain hint), fall back to node's
|
|
180
|
+
// raw message rather than dropping the error entirely.
|
|
129
181
|
const wallVendor = detectWallVendor(run);
|
|
130
182
|
const errMsg = run.errorMessage ?? run.errorSnapshot?.errorMessage;
|
|
131
|
-
|
|
183
|
+
// trawl_cli#182 — same `status`/`statusDetail` guard as `detectWallVendor`
|
|
184
|
+
// above, and for the same reason (see its doc comment): `failureKind` is a
|
|
185
|
+
// terminal classification trawl_node stamps once
|
|
186
|
+
// (modules/historys/services/historys.service.js ~line 837), but
|
|
187
|
+
// `patchForRegression` (modules/scraps/services/scraps.service.js ~line
|
|
188
|
+
// 1972) can flip `status`/`statusDetail` to success/regression LATER,
|
|
189
|
+
// without ever clearing `failureKind`. Left unguarded, a run that
|
|
190
|
+
// ultimately succeeded or degraded could still carry a stale
|
|
191
|
+
// `failureKind:'auth'` and render the login-wall hint (or the
|
|
192
|
+
// conflicting-signals hedge) right next to a green "success" badge or a
|
|
193
|
+
// "Regression: length N vs baseline M" line — the exact contradiction
|
|
194
|
+
// `detectWallVendor`'s guard already exists to prevent for `block.kind`.
|
|
195
|
+
// One rule, not two coincidences: both checks guard the same two fields
|
|
196
|
+
// against the same after-the-fact patch.
|
|
197
|
+
const authWall = run.failureKind === 'auth' && run.status !== true && run.statusDetail !== 'regression';
|
|
198
|
+
const authHintWillRender = authWall && Boolean(scrapId);
|
|
199
|
+
const conflictingSignals = Boolean(wallVendor) && authWall;
|
|
200
|
+
if (conflictingSignals) {
|
|
201
|
+
lines.push(chalk.dim(' Error: ') + chalk.yellow(`conflicting signals — the server reported ${wallVendor} on this run, but also classified it as a login wall; can't tell which is true here`));
|
|
202
|
+
lines.push(chalk.dim(scrapId
|
|
203
|
+
? ` → cookies might help, but this may still be a genuine ${wallVendor} block: trawl scraps account session set ${scrapId} -c cookies.json`
|
|
204
|
+
: ` → cookies might help, but this may still be a genuine ${wallVendor} block (see app Settings → Account)`));
|
|
205
|
+
}
|
|
206
|
+
else if (wallVendor) {
|
|
132
207
|
lines.push(chalk.dim(' Error: ') + chalk.red(`walled: ${wallVendor} — no reliable bypass`));
|
|
133
208
|
}
|
|
134
|
-
else if (errMsg) {
|
|
209
|
+
else if (errMsg && !authHintWillRender) {
|
|
135
210
|
lines.push(chalk.dim(' Error: ') + chalk.red(errMsg));
|
|
136
211
|
}
|
|
137
212
|
// Failed selector
|
|
@@ -149,8 +224,32 @@ export function formatDoctor(scrapTitle, run, fix = null, scrapId) {
|
|
|
149
224
|
const page = run.emptyContext.page;
|
|
150
225
|
lines.push(chalk.dim(' Empty context: ') + `url=${page.url ?? '?'} anchors=${page.totalAnchors ?? '?'}`);
|
|
151
226
|
}
|
|
152
|
-
//
|
|
153
|
-
|
|
227
|
+
// Login-wall hint (trawl_node#1975 `failureKind==='auth'`) — trust node's
|
|
228
|
+
// verdict, never re-detect the login URL client-side. Hypothesis-framed
|
|
229
|
+
// ("likely fix"), not a promise: measured cases (Reddit/X/Instagram) had
|
|
230
|
+
// valid cookies and stayed walled. Gated on `scrapId` alone (via
|
|
231
|
+
// `authHintWillRender` above, shared with the generic error line so the
|
|
232
|
+
// two can't drift apart), NOT the `scrapId ?? run._id` fallback used
|
|
233
|
+
// elsewhere here — `run._id` is a history id, and the session-set command
|
|
234
|
+
// on one silently targets the wrong document.
|
|
235
|
+
//
|
|
236
|
+
// trawl_cli#182 — excludes `conflictingSignals`: when a wall-vendor match
|
|
237
|
+
// also fired, the combined hedged line above already covers the cookie
|
|
238
|
+
// suggestion. Rendering this plain, unhedged block too would stack a
|
|
239
|
+
// second verdict under the first (and directly contradict it, since this
|
|
240
|
+
// block asserts the run IS a login wall with no caveat) — exactly the
|
|
241
|
+
// two-verdicts-for-one-run defect this fix removes. Exactly one verdict
|
|
242
|
+
// block renders per run.
|
|
243
|
+
if (authHintWillRender && !conflictingSignals) {
|
|
244
|
+
lines.push('');
|
|
245
|
+
lines.push(chalk.yellow(' Login wall (hypothesis): ') + 'this looks like a login redirect — your own session cookies are the likely fix.');
|
|
246
|
+
lines.push(chalk.dim(` → trawl scraps account session set ${scrapId} -c cookies.json (or app Settings → Account)`));
|
|
247
|
+
}
|
|
248
|
+
// Regression detail. trawl_node#1950 dropped the redundant `regressionDetected`
|
|
249
|
+
// boolean — it was `true` on every row iff `statusDetail === 'regression'`,
|
|
250
|
+
// so read that directly (works against a pre- or post-#1950 backend alike,
|
|
251
|
+
// no CLI/backend sequencing gap: `statusDetail` isn't part of this rename).
|
|
252
|
+
if (run.statusDetail === 'regression') {
|
|
154
253
|
lines.push(chalk.dim(' Regression: ') + `length ${run.length ?? '?'} vs baseline ${run.baselineLength ?? '?'}`);
|
|
155
254
|
}
|
|
156
255
|
// Autofix summary (when fix exists)
|
package/dist/commands/scraps.js
CHANGED
|
@@ -1234,7 +1234,9 @@ export function attachHistoryCommand(parent, attachOpts = {}) {
|
|
|
1234
1234
|
time: h.time ?? null,
|
|
1235
1235
|
tier: h.proxyTier ?? null,
|
|
1236
1236
|
failureKind: h.failureKind ?? null,
|
|
1237
|
-
blockType
|
|
1237
|
+
// trawl_node#1950 renamed blockType to block.kind; fall back to the
|
|
1238
|
+
// deprecated flat field for a prod backend not yet on that tag.
|
|
1239
|
+
blockType: h.block?.kind ?? h.blockType ?? null,
|
|
1238
1240
|
createdAt: h.createdAt ?? null,
|
|
1239
1241
|
}));
|
|
1240
1242
|
if (opts.json) {
|
|
@@ -1260,7 +1262,9 @@ export function attachRunInfoCommand(parent, attachOpts = {}) {
|
|
|
1260
1262
|
time: h.time ?? null,
|
|
1261
1263
|
tier: h.proxyTier ?? null,
|
|
1262
1264
|
failureKind: h.failureKind ?? null,
|
|
1263
|
-
blockType
|
|
1265
|
+
// trawl_node#1950 renamed blockType to block.kind; fall back to the
|
|
1266
|
+
// deprecated flat field for a prod backend not yet on that tag.
|
|
1267
|
+
blockType: h.block?.kind ?? h.blockType ?? null,
|
|
1264
1268
|
errorMessage: h.errorSnapshot?.errorMessage ?? null,
|
|
1265
1269
|
selector: h.errorSnapshot?.selector ?? null,
|
|
1266
1270
|
emptyContext: h.emptyContext ?? null,
|