@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.
@@ -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 (or auth) name from a worker `blockType`
39
- * string. Returns null when the run succeeded, when there is no `blockType`
40
- * signal at all, or when `blockType` names something other than a known
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 `blockType` but never `blocked` — 63 of 64 walled runs
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 `blockType` stamp
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.
@@ -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 (+ auth) that the worker's `blockType` field may name.
6
- * Ordered by first-match; `blockType` is a freeform worker string, not an enum
7
- * (see the `Run.blockType` doc comment), so this is a best-effort substring
8
- * match against the real field — never an invented/mocked value.
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` and `auth` are forward-compatible:
12
- * the issue (#62 / wall-registry WS-7) names them as genuine walls, but the
13
- * current worker vocabulary does not yet emit them — these branches light up
14
- * automatically if/when a future worker classifier does, without a CLI change.
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
- * The `auth` pattern is delimiter-anchored (`^`/`$`/`-_:`) rather than a bare
17
- * substring so it matches worker-style tokens (`auth`, `auth-wall`, `auth_wall`,
18
- * `login:auth`) without false-matching `oauth`/`authorization`/`author`. It uses
19
- * `[-_:]` (not `\b`) because `_` is a JS `\w` char, so `\bauth\b` would miss the
20
- * underscore-delimited `auth_wall` form the worker's `rate_limited_per_host`-style
21
- * naming favors.
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 (or auth) name from a worker `blockType`
33
- * string. Returns null when the run succeeded, when there is no `blockType`
34
- * signal at all, or when `blockType` names something other than a known
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 `blockType` but never `blocked` — 63 of 64 walled runs
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 `blockType` stamp
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 `blockType` — so keying on `status` alone would
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
- if (!run.blockType)
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(run.blockType))
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', 'blockType', 'proxyTier', 'regressionDetected', 'baselineLength',
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/auth terminal verdict from the worker),
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
- if (wallVendor) {
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
- // Regression detail
153
- if (run.regressionDetected) {
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)
@@ -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: h.blockType ?? null,
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: h.blockType ?? null,
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trawlme/cli",
3
- "version": "3.9.0",
3
+ "version": "3.9.1",
4
4
  "description": "Trawl CLI — manage scraps from the terminal",
5
5
  "type": "module",
6
6
  "bin": {