@trawlme/cli 3.7.1 → 3.7.4

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
@@ -79,7 +79,7 @@ trawl ping [--json] Health/version handshake against
79
79
  ### Scrap management
80
80
 
81
81
  ```
82
- trawl scraps create -t <title> [-u <url>] [-r <request>] [-d <description>] [--tier <tier0|tier1|tier2|tier3|tier4>] [--json]
82
+ trawl scraps create -t <title> -u <url> [-r <request>] [-d <description>] [--tier <tier0|tier1|tier2|tier3|tier4>] [--json]
83
83
  trawl scraps update <id> [-t <title>] [-u <url>] [-r <request>] [-d <description>] [--cron <expr>|--no-cron] [--alert <email>|--no-alert] [--autofix|--no-autofix] [-p <json>|--params-file <path>] [--tier <tier0|tier1|tier2|tier3|tier4>] [--force-tier <tier0|tier1|tier2|tier3|tier4>] [--json]
84
84
  trawl scraps watch <id> [--json]
85
85
  trawl scraps doctor <id> [--json] [--autofix]
@@ -89,6 +89,7 @@ trawl scraps banner <id> -f <file> [--json]
89
89
  trawl scraps delete <id> [--force] [--json] Alias: rm
90
90
  ```
91
91
 
92
+ - `-u/--url` is **the site the scrap targets**, not necessarily the exact URL fetched at run time — a scrap whose real target is computed inside its `-r/--request` script (a templated search query, an interpolated item id, e.g. `https://www.ebay.com/sch/i.html?_nkw=${query}`) should still supply a base/site URL. Every domain-level policy (proxy tier ceiling, provider routing, per-domain memoize) keys off this field, so a blank value silently disables all of it. `create` requires it — validated client-side (must parse as an `http`/`https` URL) so a missing or malformed value fails fast with a readable usage error (exit 2) instead of a raw server 400/422; `update` keeps it optional (omitting it is still a no-op, matching the API) but a value that **is** passed is validated the same way.
92
93
  - `--tier` forces a proxy tier; `--force-tier` raises the proxy-tier ceiling past the auto-cap (history-gated: may be refused or cost more). `create --json`/`update --json` print the full scrap object (including the `_tierOverride` outcome) on stdout; a refused tier override exits 1 with a standard `--json` error envelope (`kind:"refused"` — distinct from `"unknown"`, so a script can branch on "the server said no"). When a tier was requested but the server's response carries no `_tierOverride` at all (an older server that can't confirm what actually got applied), a stderr warning is printed either way, and under `--json` the emitted object also carries `"_tierUnconfirmed": true` — the machine-readable counterpart to that warning, since a `--json` caller has no reliable reason to read stderr.
93
94
  - `scraps doctor` diagnoses the last run (error, failed selector, block status, page state, autofix outcome); `--autofix` includes the full autofix diff/dry-run/knowledge. A run that is still in flight (`status: null`, server-side) shows a `running` badge, never `failed`; a run whose item count regressed vs baseline (`statusDetail: "regression"`) shows its own amber `regression` badge, never `failed` either.
94
95
  - `scraps autofix` shows the last auto-fix attempt on its own (decision, diff, dry-run, knowledge). `--json` on a scrap that has **never run** returns `{"status":"no_runs"}` (exit 0) — distinct from `null`, which means a run exists but had no auto-fix attempt.
@@ -3,6 +3,7 @@ import chalk from 'chalk';
3
3
  import { spin } from '../lib/spinner.js';
4
4
  import { api, LONG_RUN_TIMEOUT_MS, NetworkError } from '../lib/api.js';
5
5
  import { json } from '../lib/format.js';
6
+ import { parseServerJson } from '../lib/json.js';
6
7
  import { requireUrl, requireString } from '../lib/validate.js';
7
8
  import { UsageError } from '../lib/errors.js';
8
9
  import { renderPinch, pinchEnabled } from '../lib/pinch.js';
@@ -67,7 +68,10 @@ async function printDataSample(historyId) {
67
68
  const detail = await api.get(`/api/historys/${historyId}`);
68
69
  if (typeof detail?.data !== 'string' || !detail.data)
69
70
  return;
70
- const items = JSON.parse(detail.data)?.data;
71
+ // #159 same JSON-string-within-JSON shape as scrap.history[0].data;
72
+ // tolerate the documented raw-control-char server quirk here too instead
73
+ // of silently losing the sample to the outer try/catch below.
74
+ const items = parseServerJson(detail.data)?.data;
71
75
  if (!Array.isArray(items) || items.length === 0)
72
76
  return;
73
77
  console.log(chalk.dim(` Sample: `) + `${items.length} item${items.length === 1 ? '' : 's'}`);
@@ -3,8 +3,9 @@ import chalk from 'chalk';
3
3
  import { spin } from '../lib/spinner.js';
4
4
  import { api, LONG_RUN_TIMEOUT_MS } from '../lib/api.js';
5
5
  import { table, json, formatDate } from '../lib/format.js';
6
+ import { parseServerJson } from '../lib/json.js';
6
7
  import { promptPassword } from '../lib/prompt.js';
7
- import { validateObjectId } from '../lib/validate.js';
8
+ import { validateObjectId, requireUrl } from '../lib/validate.js';
8
9
  import { classifyError, reportError, UsageError, RefusalError } from '../lib/errors.js';
9
10
  import { confirmDestructive, isInteractive } from '../lib/confirm.js';
10
11
  import { formatDoctor, formatAutofix, fetchRunAndFix, pickRun, pickFix } from './doctor.js';
@@ -21,6 +22,31 @@ import { maybeShowReferralTip } from '../lib/tips.js';
21
22
  function usageError(message, opts = {}) {
22
23
  process.exitCode = reportError(new UsageError(message), { json: opts.json });
23
24
  }
25
+ /**
26
+ * #163 — client-side URL validation shared by `create`/`update`, mirroring
27
+ * the server's now-required-and-parseable `url` field (trawl_node#1823,
28
+ * PR #1828: `z.string().trim().default('')` -> `z.string().trim().url()`).
29
+ * Gives a readable message BEFORE the request goes out instead of a raw
30
+ * 400/422 from the server. Reuses the exact same `requireUrl` helper
31
+ * `trawl create`/`trawl login --url` already validate against (lib/validate.js),
32
+ * just routed through this file's own return-style `usageError()` convention
33
+ * (never throw — every other validation in this file, tier/limit/page/params/
34
+ * status, follows the same pattern) instead of the throw-style those two
35
+ * commands use. Returns `undefined` (never throws) on a bad value — the
36
+ * caller must check for that and `return` without calling the API.
37
+ */
38
+ function tryValidateUrl(value, opts) {
39
+ try {
40
+ return requireUrl(value, '--url');
41
+ }
42
+ catch (err) {
43
+ if (err instanceof UsageError) {
44
+ usageError(err.message, { json: opts.json });
45
+ return undefined;
46
+ }
47
+ throw err;
48
+ }
49
+ }
24
50
  function lastStatus(scrap) {
25
51
  const last = scrap.history?.[0];
26
52
  if (!last)
@@ -69,7 +95,11 @@ async function watchActivities(id, asJson) {
69
95
  console.log(chalk.dim('Streaming activities (Ctrl+C to stop)…\n'));
70
96
  for await (const event of api.stream(`/api/scraps/${id}/activities/stream`)) {
71
97
  try {
72
- const activity = JSON.parse(event);
98
+ // #159 same tolerant parse as api.ts's response-parsing seam: an SSE
99
+ // activity line can carry the same server-side raw-control-character
100
+ // quirk as any other API payload, and this feeds `--json` NDJSON
101
+ // output too (not just create).
102
+ const activity = parseServerJson(event);
73
103
  if (asJson) {
74
104
  console.log(JSON.stringify(activity));
75
105
  continue;
@@ -560,7 +590,7 @@ scraps
560
590
  .command('create')
561
591
  .description('Create a new scrap')
562
592
  .requiredOption('-t, --title <title>', 'Scrap title')
563
- .option('-u, --url <url>', 'Target URL')
593
+ .option('-u, --url <url>', 'Target URL — the site the scrap targets, not necessarily the exact URL fetched at run time (a request script can compute that, e.g. a templated search query). Required.')
564
594
  .option('-r, --request <request>', 'Request/query')
565
595
  .option('-d, --description <text>', 'Scrap description')
566
596
  .option('--tier <tier>', `Force proxy tier (${VALID_TIERS.join('|')})`)
@@ -570,9 +600,16 @@ scraps
570
600
  usageError(`Invalid --tier "${opts.tier}" (allowed: ${VALID_TIERS.join(', ')})`, { json: opts.json });
571
601
  return;
572
602
  }
603
+ // #163 — the API now requires a parseable `url` on create (trawl_node
604
+ // #1823/#1828); validate client-side BEFORE the request goes out so a
605
+ // missing/unparseable value surfaces as a readable usage error instead
606
+ // of a raw server 400/422.
607
+ const url = tryValidateUrl(opts.url, opts);
608
+ if (url === undefined)
609
+ return;
573
610
  const data = await spin(() => api.post('/api/scraps', {
574
611
  title: opts.title,
575
- ...(opts.url && { url: opts.url }),
612
+ url,
576
613
  request: opts.request || '',
577
614
  ...(opts.description !== undefined && { description: opts.description }),
578
615
  ...(opts.tier !== undefined && { proxyTier: opts.tier }),
@@ -601,7 +638,7 @@ scraps
601
638
  .command('update <id>')
602
639
  .description('Update an existing scrap')
603
640
  .option('-t, --title <title>', 'New title')
604
- .option('-u, --url <url>', 'New target URL')
641
+ .option('-u, --url <url>', 'New target URL — the site the scrap targets. Optional: omitting it leaves the current value unchanged (a no-op, matching the API); when passed it must still be a parseable URL.')
605
642
  .option('-r, --request <request>', 'New request')
606
643
  .option('-d, --description <text>', 'New description')
607
644
  .option('--cron <expression>', 'Cron expression (empty string to disable)')
@@ -628,8 +665,15 @@ scraps
628
665
  const body = {};
629
666
  if (opts.title !== undefined)
630
667
  body.title = opts.title;
631
- if (opts.url !== undefined)
632
- body.url = opts.url;
668
+ // #163 — omitting --url stays a no-op (the API keeps that allowed on
669
+ // update), but a value that IS supplied must still be a parseable URL —
670
+ // validate it client-side before the request goes out, same as create.
671
+ if (opts.url !== undefined) {
672
+ const url = tryValidateUrl(opts.url, opts);
673
+ if (url === undefined)
674
+ return;
675
+ body.url = url;
676
+ }
633
677
  if (opts.request !== undefined)
634
678
  body.request = opts.request;
635
679
  if (opts.description !== undefined)
@@ -939,7 +983,12 @@ export function attachDataCommand(parent, attachOpts = {}) {
939
983
  let items;
940
984
  if (typeof detail?.data === 'string' && detail.data) {
941
985
  try {
942
- items = JSON.parse(detail.data)?.data;
986
+ // #159 — `detail.data` is the same JSON-string-within-JSON shape
987
+ // (`history.data`) the create --json bug lived in: a raw control
988
+ // character here would otherwise throw and silently report "aged
989
+ // out of retention" below for data that is actually present and
990
+ // recoverable, burning a --fresh re-run + quota for nothing.
991
+ items = parseServerJson(detail.data)?.data;
943
992
  }
944
993
  catch {
945
994
  items = undefined;
@@ -1157,8 +1206,28 @@ export function attachTriggerCommand(parent, attachOpts = {}) {
1157
1206
  text: opts.wait ? 'Running worker…' : 'Triggering worker…',
1158
1207
  successText: opts.wait ? 'Worker run complete' : 'Worker triggered',
1159
1208
  });
1160
- if (opts.json)
1209
+ if (opts.json) {
1161
1210
  json(data);
1211
+ }
1212
+ else if (!process.stdout.isTTY) {
1213
+ // #160 — spin() (lib/spinner.ts, #119) intentionally emits NOTHING
1214
+ // when stderr isn't a TTY, so a piped/redirected `trigger` wrote zero
1215
+ // bytes to both stdout AND stderr while exiting 0: a caller driving
1216
+ // this from a script has no way to tell success from a no-op. Gated
1217
+ // on STDOUT specifically — that's the stream a script actually reads
1218
+ // (`$(trawl trigger …)`, `> out.txt`, `| jq`), independent of
1219
+ // whatever spin() decides about stderr — so an interactive TTY
1220
+ // session, where the ora spinner already confirmed this on stderr,
1221
+ // never sees a duplicate line here. Keeps the exact "Worker
1222
+ // triggered"/"Worker run complete" substrings the trawl-internal QA
1223
+ // runbook asserts on, plus the scrap id so a script has something to
1224
+ // parse. Known residual: stdout attached to a real terminal while
1225
+ // stderr is separately redirected (rare) still prints nothing on
1226
+ // either stream, same as pre-#160 — not the reported/common shape
1227
+ // (full redirection or stdout-only capture), left alone to keep this
1228
+ // fix narrowly scoped to the stream it actually touches.
1229
+ console.log(opts.wait ? `Worker run complete for scrap ${id}` : `Worker triggered for scrap ${id}`);
1230
+ }
1162
1231
  // #107 — see the matching comment on `run`'s --watch call above (review
1163
1232
  // F1): honest final NDJSON line + exit code under --json, human mode
1164
1233
  // gets the same honest exit code too.
package/dist/lib/api.js CHANGED
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { dirname, resolve } from 'node:path';
4
4
  import { getApiUrl, getToken } from './config.js';
5
+ import { parseServerJson } from './json.js';
5
6
  const __dirname = dirname(fileURLToPath(import.meta.url));
6
7
  const pkg = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf8'));
7
8
  const USER_AGENT = `@trawlme/cli/${pkg.version}`;
@@ -135,13 +136,17 @@ function extractErrorMessage(raw, statusText) {
135
136
  if (!raw)
136
137
  return '';
137
138
  try {
138
- const parsed = JSON.parse(raw);
139
+ // #159 an error envelope's message/description/nested `error` string can
140
+ // carry the same raw-control-char server quirk as a success payload;
141
+ // recover it here too instead of falling all the way back to the raw,
142
+ // unparsed blob as the "error message".
143
+ const parsed = parseServerJson(raw);
139
144
  if (parsed && typeof parsed === 'object') {
140
145
  const env = parsed;
141
146
  const nested = typeof env.error === 'string' ? env.error : null;
142
147
  if (nested) {
143
148
  try {
144
- const inner = JSON.parse(nested);
149
+ const inner = parseServerJson(nested);
145
150
  const details = inner.details;
146
151
  if (details && typeof details.message === 'string')
147
152
  return details.message;
@@ -178,7 +183,7 @@ function extractErrorMessage(raw, statusText) {
178
183
  */
179
184
  function extractUpgradeUrl(raw) {
180
185
  try {
181
- const parsed = JSON.parse(raw);
186
+ const parsed = parseServerJson(raw);
182
187
  if (typeof parsed.upgradeUrl === 'string')
183
188
  return parsed.upgradeUrl;
184
189
  const details = parsed.details;
@@ -187,7 +192,7 @@ function extractUpgradeUrl(raw) {
187
192
  }
188
193
  if (typeof parsed.error === 'string') {
189
194
  try {
190
- const inner = JSON.parse(parsed.error);
195
+ const inner = parseServerJson(parsed.error);
191
196
  if (typeof inner.upgradeUrl === 'string')
192
197
  return inner.upgradeUrl;
193
198
  const innerDetails = inner.details;
@@ -252,7 +257,7 @@ async function request(path, options = {}, reqOpts = {}) {
252
257
  try {
253
258
  if (!text)
254
259
  return {};
255
- const parsed = JSON.parse(text);
260
+ const parsed = parseServerJson(text);
256
261
  // Unwrap API envelope { type, message, data: T }
257
262
  if (parsed !== null && typeof parsed === 'object' && 'data' in parsed) {
258
263
  return parsed.data;
@@ -284,7 +289,7 @@ async function upload(path, formData, reqOpts = {}) {
284
289
  try {
285
290
  if (!text)
286
291
  return {};
287
- const parsed = JSON.parse(text);
292
+ const parsed = parseServerJson(text);
288
293
  // Unwrap API envelope { type, message, data: T }
289
294
  if (parsed !== null && typeof parsed === 'object' && 'data' in parsed) {
290
295
  return parsed.data;
@@ -307,7 +312,7 @@ async function publicPost(path, body, baseUrlOverride, reqOpts = {}) {
307
312
  await throwIfError(res, true);
308
313
  const text = await res.text();
309
314
  try {
310
- const data = text ? JSON.parse(text) : {};
315
+ const data = text ? parseServerJson(text) : {};
311
316
  return { data, headers: res.headers };
312
317
  }
313
318
  catch {
@@ -344,7 +349,7 @@ async function publicGet(path, reqOpts = {}) {
344
349
  try {
345
350
  if (!text)
346
351
  return {};
347
- const parsed = JSON.parse(text);
352
+ const parsed = parseServerJson(text);
348
353
  // Unwrap API envelope { type, message, data: T }
349
354
  if (parsed !== null && typeof parsed === 'object' && 'data' in parsed) {
350
355
  return parsed.data;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Shared, tolerant JSON parsing for server response bodies (#159).
3
+ *
4
+ * The Trawl API is known to occasionally emit a raw, unescaped control
5
+ * character (U+0000–U+001F — a literal newline, NUL, BEL, …) inside a JSON
6
+ * string value, most often nested inside `scrap.history[0].data` (a
7
+ * worker-envelope blob embedded as a string field of the outer response —
8
+ * the exact server-side emission point is not yet pinned down; tracked in
9
+ * comes-io/trawl_node#1768). That is invalid per RFC 8259 — our own QA
10
+ * tooling already works around it by parsing raw API responses with
11
+ * Python's `json.loads(text, strict=False)` — but native `JSON.parse`
12
+ * has no equivalent lenient mode: it throws outright, and until this fix
13
+ * every one of `api.ts`'s response-parsing call sites (`request`, `upload`,
14
+ * `publicGet`, `publicPost`) turned that into a bare "Invalid JSON in server
15
+ * response" error, discarding the ENTIRE response — even a successful
16
+ * create/run whose scrap was already persisted server-side. Exactly the
17
+ * "payload-dependent, not a constant break" shape reported in #159: it only
18
+ * fires when the specific scraped content/log text happens to carry a raw
19
+ * control byte.
20
+ *
21
+ * `parseServerJson` recovers from that ONE known shape — nothing else. It
22
+ * tries a normal strict `JSON.parse` first (the common case, zero extra
23
+ * cost) and only falls back to a sanitizing re-parse when that throws.
24
+ * Genuinely malformed JSON (truncated body, stray token, …) still throws
25
+ * after the fallback also fails — this is a targeted recovery, not a
26
+ * general-purpose lenient parser.
27
+ */
28
+ export declare function escapeRawControlCharsInJsonStrings(text: string): string;
29
+ /**
30
+ * Parse a server response body, tolerating the known #159 quirk above. Never
31
+ * silently drops data: a byte that reaches here as a real control character
32
+ * comes back out as the same character (via the escape → JSON.parse
33
+ * unescape round trip) once parsed, and JSON.stringify guarantees it is
34
+ * re-escaped correctly on the way back out through the shared `--json`
35
+ * output seam (`lib/format.ts#json`).
36
+ */
37
+ export declare function parseServerJson(text: string): unknown;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Shared, tolerant JSON parsing for server response bodies (#159).
3
+ *
4
+ * The Trawl API is known to occasionally emit a raw, unescaped control
5
+ * character (U+0000–U+001F — a literal newline, NUL, BEL, …) inside a JSON
6
+ * string value, most often nested inside `scrap.history[0].data` (a
7
+ * worker-envelope blob embedded as a string field of the outer response —
8
+ * the exact server-side emission point is not yet pinned down; tracked in
9
+ * comes-io/trawl_node#1768). That is invalid per RFC 8259 — our own QA
10
+ * tooling already works around it by parsing raw API responses with
11
+ * Python's `json.loads(text, strict=False)` — but native `JSON.parse`
12
+ * has no equivalent lenient mode: it throws outright, and until this fix
13
+ * every one of `api.ts`'s response-parsing call sites (`request`, `upload`,
14
+ * `publicGet`, `publicPost`) turned that into a bare "Invalid JSON in server
15
+ * response" error, discarding the ENTIRE response — even a successful
16
+ * create/run whose scrap was already persisted server-side. Exactly the
17
+ * "payload-dependent, not a constant break" shape reported in #159: it only
18
+ * fires when the specific scraped content/log text happens to carry a raw
19
+ * control byte.
20
+ *
21
+ * `parseServerJson` recovers from that ONE known shape — nothing else. It
22
+ * tries a normal strict `JSON.parse` first (the common case, zero extra
23
+ * cost) and only falls back to a sanitizing re-parse when that throws.
24
+ * Genuinely malformed JSON (truncated body, stray token, …) still throws
25
+ * after the fallback also fails — this is a targeted recovery, not a
26
+ * general-purpose lenient parser.
27
+ */
28
+ /**
29
+ * Escape any raw control character (U+0000–U+001F) found INSIDE a JSON
30
+ * string literal, leaving everything else — including legitimate raw
31
+ * whitespace between tokens (space/tab/CR/LF are valid there per RFC 8259) —
32
+ * untouched. A small state machine tracks whether the scan is currently
33
+ * inside a `"…"` string and whether the previous character was an unescaped
34
+ * backslash; it does not otherwise validate structure, so text that is
35
+ * invalid JSON for any OTHER reason still fails the subsequent `JSON.parse`
36
+ * call unchanged.
37
+ */
38
+ const NAMED_CONTROL_CHAR_ESCAPES = {
39
+ '\n': '\\n',
40
+ '\r': '\\r',
41
+ '\t': '\\t',
42
+ '\b': '\\b',
43
+ '\f': '\\f',
44
+ };
45
+ export function escapeRawControlCharsInJsonStrings(text) {
46
+ let out = '';
47
+ let inString = false;
48
+ let escaped = false;
49
+ for (let i = 0; i < text.length; i += 1) {
50
+ const ch = text[i];
51
+ const code = text.charCodeAt(i);
52
+ if (!inString) {
53
+ if (ch === '"')
54
+ inString = true;
55
+ out += ch;
56
+ continue;
57
+ }
58
+ if (escaped) {
59
+ out += ch;
60
+ escaped = false;
61
+ continue;
62
+ }
63
+ if (ch === '\\') {
64
+ out += ch;
65
+ escaped = true;
66
+ continue;
67
+ }
68
+ if (ch === '"') {
69
+ inString = false;
70
+ out += ch;
71
+ continue;
72
+ }
73
+ if (code <= 0x1f) {
74
+ // Raw control character inside a string literal — recover the same
75
+ // way JSON.stringify would have escaped it on the way out, instead of
76
+ // losing the whole response to a SyntaxError.
77
+ out += NAMED_CONTROL_CHAR_ESCAPES[ch] ?? `\\u${code.toString(16).padStart(4, '0')}`;
78
+ continue;
79
+ }
80
+ out += ch;
81
+ }
82
+ return out;
83
+ }
84
+ /**
85
+ * Parse a server response body, tolerating the known #159 quirk above. Never
86
+ * silently drops data: a byte that reaches here as a real control character
87
+ * comes back out as the same character (via the escape → JSON.parse
88
+ * unescape round trip) once parsed, and JSON.stringify guarantees it is
89
+ * re-escaped correctly on the way back out through the shared `--json`
90
+ * output seam (`lib/format.ts#json`).
91
+ */
92
+ export function parseServerJson(text) {
93
+ try {
94
+ return JSON.parse(text);
95
+ }
96
+ catch (firstErr) {
97
+ try {
98
+ return JSON.parse(escapeRawControlCharsInJsonStrings(text));
99
+ }
100
+ catch {
101
+ throw firstErr;
102
+ }
103
+ }
104
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trawlme/cli",
3
- "version": "3.7.1",
3
+ "version": "3.7.4",
4
4
  "description": "Trawl CLI — manage scraps from the terminal",
5
5
  "type": "module",
6
6
  "bin": {