@trawlme/cli 1.16.0 → 1.18.0

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
@@ -26,27 +26,43 @@ Custom API URL: `trawl login --url https://self-hosted.example.com`
26
26
 
27
27
  ## Commands
28
28
 
29
+ All commands accept a global `--debug` flag to show full error stack traces on failure.
30
+
29
31
  ### Auth
30
32
 
31
33
  ```
32
34
  trawl login [--url <url>] [--token <jwt>] [--email <email>] [--password <pass>]
33
35
  trawl logout
36
+ trawl token Print the stored session JWT (for MCP Bearer auth)
34
37
  ```
35
38
 
36
39
  ### Scraps
37
40
 
38
41
  ```
39
- trawl scraps list [--json] [--status <success|failure|never>]
42
+ trawl scraps list [--json] [--status <success|failure|never>] [--limit <n>] [--page <n>]
40
43
  trawl scraps get <id> [--json]
41
- trawl scraps create -t <title> [-u <url>] [-r <request>] [-s <scrapper>]
42
- trawl scraps update <id> [-t title] [-u <url>] [-r request] [-s scrapper] [--cron <expr>|--no-cron] [--alert <email>|--no-alert] [-p <json>|--params-file <path>]
44
+ trawl scraps create -t <title> [-u <url>] [-r <request>] [-d <description>] [--tier <tier0|tier1|tier2|tier3|tier4>]
45
+ 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>]
43
46
  trawl scraps run <id> [--watch]
44
- trawl scraps trigger <id> [--watch]
47
+ trawl scraps trigger <id> [--watch] [--wait]
45
48
  trawl scraps watch <id>
46
- trawl scraps data <id> [--json]
49
+ trawl scraps data <id> [--json] [--fresh] [--errors]
50
+ trawl scraps history <id> [--json] [-n <limit>]
51
+ trawl scraps run-info <hid> [--json]
52
+ trawl scraps doctor <id> [--json] [--autofix]
53
+ trawl scraps autofix <id> [--json]
54
+ trawl scraps snapshot <id> [--error] [-o <file>]
55
+ trawl scraps banner <id> -f <file>
47
56
  trawl scraps rm <id> [--force]
48
57
  ```
49
58
 
59
+ - `--tier` forces a proxy tier; `--force-tier` raises the proxy-tier ceiling past the auto-cap (history-gated: may be refused or cost more).
60
+ - `scraps doctor` diagnoses the last run (error, failed selector, block status, page state, autofix outcome); `--autofix` includes the full autofix diff/dry-run/knowledge.
61
+ - `scraps autofix` shows the last auto-fix attempt on its own (decision, diff, dry-run, knowledge).
62
+ - `scraps snapshot --error` fetches the error-path snapshot instead of the normal one; `-o <file>` writes to a file instead of stdout.
63
+ - `scraps history` lists past runs (newest first); `scraps run-info <hid>` shows details of a single run from that history.
64
+ - `scraps data` returns the last persisted run payload (no execute quota); `--fresh` runs the scrap live instead (consumes execute quota); `--errors` shows the last run's error detail.
65
+
50
66
  ### Scrap accounts
51
67
 
52
68
  ```
@@ -54,8 +70,11 @@ trawl scraps account set <id> [-u <username>] [-p <password>]
54
70
  trawl scraps account delete <id> [--force]
55
71
  trawl scraps account clear-session <id>
56
72
  trawl scraps account status <id> [--json]
73
+ trawl scraps account session set <id> -c <file>
57
74
  ```
58
75
 
76
+ `account session set` uploads a Puppeteer cookie JSON array to bootstrap a logged-in session without storing credentials (BYO-cookies).
77
+
59
78
  ### Claude Code skills
60
79
 
61
80
  The CLI bundles a Claude Code skill that teaches Claude how to use `trawl`. Once installed, Claude can manage scraps for you via prompts.
@@ -25,6 +25,7 @@ export interface Run {
25
25
  selectors?: Record<string, number>;
26
26
  } | null;
27
27
  blocked?: boolean;
28
+ blockType?: string | null;
28
29
  proxyTier?: string | null;
29
30
  regressionDetected?: boolean;
30
31
  baselineLength?: number | null;
@@ -33,6 +34,14 @@ export interface Run {
33
34
  time?: number | null;
34
35
  triggeredBy?: string | null;
35
36
  }
37
+ /**
38
+ * Resolve a known anti-bot vendor (or auth) name from a worker `blockType`
39
+ * string. Returns null when the run isn't blocked, or when it's blocked by
40
+ * something other than a known vendor (e.g. `proxy-domain-gate`,
41
+ * `rate_limited_per_host`) — those stay on the genuine-error path since we
42
+ * can't honestly attribute them to a specific "no reliable bypass" wall.
43
+ */
44
+ export declare function detectWallVendor(run: Pick<Run, 'blocked' | 'blockType'>): string | null;
36
45
  /**
37
46
  * Autofix activity metadata — from the persisted ai_fix_end activity.
38
47
  * aiUsage (cost) is stripped server-side; all diagnostics are kept.
@@ -1,5 +1,48 @@
1
1
  import { api } from '../lib/api.js';
2
2
  import chalk from 'chalk';
3
+ /**
4
+ * Known anti-bot vendors (+ auth) that the worker's `blockType` field may name.
5
+ * Ordered by first-match; `blockType` is a freeform worker string, not an enum
6
+ * (see the `Run.blockType` doc comment), so this is a best-effort substring
7
+ * match against the real field — never an invented/mocked value.
8
+ *
9
+ * `datadome`/`perimeterx`/`akamai`/`cloudflare` are literal substrings the
10
+ * current worker emits (detect.js). `kasada` and `auth` are forward-compatible:
11
+ * the issue (#62 / wall-registry WS-7) names them as genuine walls, but the
12
+ * current worker vocabulary does not yet emit them — these branches light up
13
+ * automatically if/when a future worker classifier does, without a CLI change.
14
+ *
15
+ * The `auth` pattern is delimiter-anchored (`^`/`$`/`-_:`) rather than a bare
16
+ * substring so it matches worker-style tokens (`auth`, `auth-wall`, `auth_wall`,
17
+ * `login:auth`) without false-matching `oauth`/`authorization`/`author`. It uses
18
+ * `[-_:]` (not `\b`) because `_` is a JS `\w` char, so `\bauth\b` would miss the
19
+ * underscore-delimited `auth_wall` form the worker's `rate_limited_per_host`-style
20
+ * naming favors.
21
+ */
22
+ const WALL_VENDOR_PATTERNS = [
23
+ [/datadome/i, 'DataDome'],
24
+ [/kasada/i, 'Kasada'],
25
+ [/perimeterx/i, 'PerimeterX'],
26
+ [/akamai/i, 'Akamai'],
27
+ [/cloudflare/i, 'Cloudflare'],
28
+ [/(?:^|[-_:])auth(?:$|[-_:])/i, 'auth'],
29
+ ];
30
+ /**
31
+ * Resolve a known anti-bot vendor (or auth) name from a worker `blockType`
32
+ * string. Returns null when the run isn't blocked, or when it's blocked by
33
+ * something other than a known vendor (e.g. `proxy-domain-gate`,
34
+ * `rate_limited_per_host`) — those stay on the genuine-error path since we
35
+ * can't honestly attribute them to a specific "no reliable bypass" wall.
36
+ */
37
+ export function detectWallVendor(run) {
38
+ if (run.blocked !== true || !run.blockType)
39
+ return null;
40
+ for (const [pattern, label] of WALL_VENDOR_PATTERNS) {
41
+ if (pattern.test(run.blockType))
42
+ return label;
43
+ }
44
+ return null;
45
+ }
3
46
  const TIER_LABELS = {
4
47
  tier0: 'Tier 0',
5
48
  tier1: 'Tier 1',
@@ -9,7 +52,7 @@ const TIER_LABELS = {
9
52
  };
10
53
  const RUN_ALLOWLIST = [
11
54
  '_id', 'status', 'statusDetail', 'length', 'errorMessage', 'errorSnapshot',
12
- 'emptyContext', 'blocked', 'proxyTier', 'regressionDetected', 'baselineLength',
55
+ 'emptyContext', 'blocked', 'blockType', 'proxyTier', 'regressionDetected', 'baselineLength',
13
56
  'fixVersionId', 'createdAt', 'time', 'triggeredBy',
14
57
  ];
15
58
  const FIX_ALLOWLIST = [
@@ -49,9 +92,15 @@ export function formatDoctor(scrapTitle, run, fix = null, scrapId) {
49
92
  : chalk.red('● failed');
50
93
  lines.push(`${chalk.bold(scrapTitle)} ${badge}${run.statusDetail ? ` (${run.statusDetail})` : ''}`);
51
94
  lines.push(chalk.dim(` Run ID: ${run._id}`));
52
- // Error message
95
+ // Error message — an honest accept-wall string for known-walled scraps
96
+ // (DataDome/Kasada/PerimeterX/Akamai/auth terminal verdict from the worker),
97
+ // otherwise the real error (genuine transient failure).
98
+ const wallVendor = detectWallVendor(run);
53
99
  const errMsg = run.errorMessage ?? run.errorSnapshot?.errorMessage;
54
- if (errMsg) {
100
+ if (wallVendor) {
101
+ lines.push(chalk.dim(' Error: ') + chalk.red(`walled: ${wallVendor} — no reliable bypass`));
102
+ }
103
+ else if (errMsg) {
55
104
  lines.push(chalk.dim(' Error: ') + chalk.red(errMsg));
56
105
  }
57
106
  // Failed selector
@@ -2,14 +2,23 @@ import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
3
  import config, { getApiUrl } from '../lib/config.js';
4
4
  import { api } from '../lib/api.js';
5
- import { requireJwt, requireUrl } from '../lib/validate.js';
5
+ import { requireFreshJwt, requireUrl } from '../lib/validate.js';
6
6
  import { promptPassword } from '../lib/prompt.js';
7
7
  async function promptEmail() {
8
8
  const { createInterface } = await import('readline');
9
9
  const rl = createInterface({ input: process.stdin, output: process.stdout });
10
10
  try {
11
- return await new Promise((resolve) => {
12
- rl.question('Email: ', (answer) => resolve(answer.trim()));
11
+ return await new Promise((resolve, reject) => {
12
+ let answered = false;
13
+ rl.question('Email: ', (answer) => {
14
+ answered = true;
15
+ resolve(answer.trim());
16
+ });
17
+ rl.on('close', () => {
18
+ if (!answered) {
19
+ reject(new Error('No input received — pass -e/-p or set TRAWL_TOKEN for non-interactive use'));
20
+ }
21
+ });
13
22
  });
14
23
  }
15
24
  finally {
@@ -23,12 +32,21 @@ export const login = new Command('login')
23
32
  .option('-e, --email <email>', 'Email address')
24
33
  .option('-p, --password <password>', 'Password (CI only — visible in process list and shell history)')
25
34
  .action(async (opts) => {
26
- if (opts.url)
27
- config.set('apiUrl', requireUrl(opts.url, '--url'));
35
+ // Validate --url but do NOT persist it yet. A failed signin must not
36
+ // brick the config by pointing it at an unreachable/wrong host while the
37
+ // OLD token stays stored (and would then be sent to that new host on the
38
+ // next command). Target this run via `pendingUrl` and persist only once
39
+ // a token has actually been obtained. (#68)
40
+ const pendingUrl = opts.url ? requireUrl(opts.url, '--url') : undefined;
41
+ const persistUrlIfPending = () => {
42
+ if (pendingUrl)
43
+ config.set('apiUrl', pendingUrl);
44
+ };
28
45
  // Check environment variable override first
29
46
  const envToken = process.env['TRAWL_TOKEN'];
30
47
  if (envToken) {
31
- config.set('token', requireJwt(envToken, 'TRAWL_TOKEN'));
48
+ config.set('token', requireFreshJwt(envToken, 'TRAWL_TOKEN'));
49
+ persistUrlIfPending();
32
50
  console.log(chalk.green('✓ Logged in'));
33
51
  console.log(chalk.dim(` API: ${getApiUrl()}`));
34
52
  console.log(chalk.dim(` Config: ${config.path}`));
@@ -36,7 +54,8 @@ export const login = new Command('login')
36
54
  }
37
55
  // --token flag: direct JWT (CI / retrocompat)
38
56
  if (opts.token) {
39
- config.set('token', requireJwt(opts.token, '--token'));
57
+ config.set('token', requireFreshJwt(opts.token, '--token'));
58
+ persistUrlIfPending();
40
59
  console.log(chalk.green('✓ Logged in'));
41
60
  console.log(chalk.dim(` API: ${getApiUrl()}`));
42
61
  console.log(chalk.dim(` Config: ${config.path}`));
@@ -48,7 +67,7 @@ export const login = new Command('login')
48
67
  }
49
68
  const email = opts.email ?? (await promptEmail());
50
69
  const password = opts.password ?? (await promptPassword('Password: '));
51
- const { data, headers } = await api.publicPost('/api/auth/signin', { email, password });
70
+ const { data, headers } = await api.publicPost('/api/auth/signin', { email, password }, pendingUrl);
52
71
  // Try token from response body first, then fall back to Set-Cookie header
53
72
  let raw = typeof data === 'string' ? data : data.token;
54
73
  if (!raw) {
@@ -60,8 +79,9 @@ export const login = new Command('login')
60
79
  if (!raw || typeof raw !== 'string') {
61
80
  throw new Error(`Invalid response from API: no token received. Got: ${JSON.stringify(data)}`);
62
81
  }
63
- const token = requireJwt(raw, 'token');
82
+ const token = requireFreshJwt(raw, 'token');
64
83
  config.set('token', token);
84
+ persistUrlIfPending();
65
85
  console.log(chalk.green(`✓ Logged in as ${email}`));
66
86
  console.log(chalk.dim(` API: ${getApiUrl()}`));
67
87
  console.log(chalk.dim(` Config: ${config.path}`));