@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 +2 -1
- package/dist/commands/create.js +5 -1
- package/dist/commands/scraps.js +78 -9
- package/dist/lib/api.js +13 -8
- package/dist/lib/json.d.ts +37 -0
- package/dist/lib/json.js +104 -0
- package/package.json +1 -1
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>
|
|
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.
|
package/dist/commands/create.js
CHANGED
|
@@ -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
|
-
|
|
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'}`);
|
package/dist/commands/scraps.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
632
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 ?
|
|
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 =
|
|
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;
|
package/dist/lib/json.js
ADDED
|
@@ -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
|
+
}
|