@zenrows/cli 1.3.1 → 1.4.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.
@@ -17,12 +17,18 @@ Use **`extract`** when you need structured JSON fields rather than a full page:
17
17
  extract({ url }) # mode=auto (default)
18
18
  extract({ url, mode: "autoparse" }) # general Autoparse (any domain)
19
19
  extract({ url, mode: "css", css_extractor: '{"title":"h1","price":".price"}' })
20
- extract({ url, js_render: true, premium_proxy: true }) # stealth flags when needed
20
+ extract({ url, proxy_country: "US" }) # geo-targeted
21
21
  ```
22
22
 
23
23
  `mode=auto` (`extract=auto`) is **open beta**: richest on enabled domains,
24
- currently free; billing may apply later. If the domain is not enabled (AUTH010),
25
- the tool retries once with Autoparse by default (`fallback_autoparse`).
24
+ currently free; billing may apply later. If the domain is not enabled (AUTH010)
25
+ or not prepared yet (REQS007), the tool retries once with Autoparse by default
26
+ (`fallback_autoparse`).
27
+
28
+ Every mode uses Adaptive Stealth Mode by default: pass just the URL, even for
29
+ protected or dynamic sites. Set `js_render` or `premium_proxy` only to force a
30
+ fixed configuration; that turns Adaptive Stealth Mode off. Check `empty` in the
31
+ result: true means no field came back with a value.
26
32
 
27
33
  ## Fallback: `scrape` extraction params
28
34
 
@@ -40,7 +46,7 @@ scrape({ url, outputs: "emails,links" }) # or "*" for all built-ins
40
46
  ## When to use which
41
47
 
42
48
  - **`mode=auto`**: site-tailored fields on beta-enabled domains.
43
- - **`mode=autoparse`**: any website; also the automatic AUTH010 fallback.
49
+ - **`mode=autoparse`**: any website; also the automatic AUTH010 / REQS007 fallback.
44
50
  - **`mode=css`**: known fields/selectors; deterministic.
45
51
  - **Markdown via `scrape`**: feed clean content to an LLM yourself.
46
52
 
@@ -69,6 +69,25 @@ export function estimateCredits(jobs) {
69
69
  });
70
70
  return { credits: perJob.reduce((a, b) => a + b, 0), perJob };
71
71
  }
72
+ /**
73
+ * Adaptive Stealth Mode (mode=auto) by default, like `fetch` and `extract`: returns
74
+ * the jobs with `mode: "auto"` on every line that doesn't force `js_render` /
75
+ * `premium_proxy` or set `mode` itself, and none at all when the job-level params
76
+ * force either flag. Applied per task rather than job-level because the API rejects
77
+ * a task that combines a job-level mode=auto with its own js_render or
78
+ * premium_proxy (REQS004).
79
+ */
80
+ export function withAdaptiveStealth(jobs, jobParams = {}) {
81
+ const forced = (p) => isTruthyFlag(p.js_render) || isTruthyFlag(p.premium_proxy);
82
+ if (forced(jobParams) || jobParams.mode != null)
83
+ return jobs;
84
+ return jobs.map((job) => {
85
+ const own = { ...(job.zenrows_params ?? {}), ...job };
86
+ if (forced(own) || own.mode != null)
87
+ return job;
88
+ return { ...job, mode: "auto" };
89
+ });
90
+ }
72
91
  /**
73
92
  * Map a validated JSONL spec + job-level flags to the Batch API request body.
74
93
  * The flat per-line options (`js_render`, `premium_proxy`, `proxy_country`,
@@ -82,7 +101,8 @@ export function estimateCredits(jobs) {
82
101
  * `protected-fetch.ts::validateAutoManual`.
83
102
  */
84
103
  export function toJobBody(jobs, jobParams = {}) {
85
- assertProxyCountryPremium(jobParams, "job-level params");
104
+ // No separate job-level check: a job-level proxy_country is valid for each task
105
+ // in Adaptive Stealth Mode, and the per-task check below sees job ⊕ task params.
86
106
  const tasks = jobs.map((job, idx) => {
87
107
  const { url, external_id, metadata, zenrows_params } = job;
88
108
  const flat = {};
@@ -9,7 +9,8 @@
9
9
  import { statSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
  import { log, ANSI, c } from "../../core/logger.js";
12
- import { estimateCredits, toJobBody, validateJsonl } from "../../adapters/batch.js";
12
+ import { estimateCredits, toJobBody, validateJsonl, withAdaptiveStealth } from "../../adapters/batch.js";
13
+ import { loadConfig } from "../../core/config.js";
13
14
  import { requireApiKey } from "../../core/auth.js";
14
15
  import { ensureApiKey } from "../../core/ensure-key.js";
15
16
  import { assertUsable } from "../../core/capabilities.js";
@@ -25,12 +26,15 @@ export const batch = {
25
26
  usage: "zenrows batch <estimate|create <file.jsonl>|status <id>|results <id>|cancel <id>|wait <id>|retry-failed <id>>",
26
27
  help: [
27
28
  "Local (no key):",
28
- " estimate <file.jsonl> validate the spec + estimate credits",
29
+ " estimate <file.jsonl> [--manual] validate the spec + estimate credits (upper bound)",
29
30
  "Cloud (needs a key + Batch beta access):",
30
31
  " create <file.jsonl> [flags] validate, then submit the job",
31
- " --js-render job-level: render JavaScript",
32
- " --premium-proxy job-level: use residential IPs",
33
- " --proxy-country <cc> job-level: geo-target (needs --premium-proxy)",
32
+ " (default) Adaptive Stealth Mode (mode=auto) on every task that",
33
+ " doesn't force js_render / premium_proxy itself",
34
+ " --manual no Adaptive Stealth Mode; plain requests unless flags below",
35
+ " --js-render job-level: force JavaScript rendering (turns auto off)",
36
+ " --premium-proxy job-level: force residential IPs (turns auto off)",
37
+ " --proxy-country <cc> job-level: geo-target (auto mode, or with --premium-proxy)",
34
38
  " --output <fmt> job-level response_type (markdown|plaintext|pdf|html)",
35
39
  " --follow poll until the run finishes (alias: --wait)",
36
40
  " --no-signup do not auto-create a Free plan account if no key exists",
@@ -76,20 +80,24 @@ export const batch = {
76
80
  }
77
81
  },
78
82
  };
83
+ /** Jobs as they will be sent: Adaptive Stealth Mode applied unless manual or disabled in config. */
84
+ function effectiveJobs(jobs, jobParams, manual) {
85
+ return manual || loadConfig().defaultMode !== "auto" ? jobs : withAdaptiveStealth(jobs, jobParams);
86
+ }
79
87
  function estimateCmd(rest, ctx) {
80
- const { positionals } = parse(rest, {});
88
+ const { values, positionals } = parse(rest, { manual: { type: "boolean" } });
81
89
  const file = positionals[0];
82
90
  if (!file)
83
91
  throw needFile();
84
92
  const v = validateJsonl(file);
85
- const est = estimateCredits(v.jobs);
93
+ const est = estimateCredits(effectiveJobs(v.jobs, {}, values.manual === true));
86
94
  if (ctx.json) {
87
95
  log.out(JSON.stringify({ ok: v.errors.length === 0, ...v, estimatedCredits: est.credits }, null, 2));
88
96
  }
89
97
  else {
90
98
  log.info(c(ANSI.bold, `Job spec: ${file}`));
91
99
  log.info(`valid jobs: ${v.validJobs}/${v.totalLines}`);
92
- log.info(`estimated credits (1x basic / 5x js / 10x proxy / 25x both): ${est.credits}`);
100
+ log.info(`estimated credits, upper bound (1x basic / 5x js / 10x proxy / 25x both or auto): ${est.credits}`);
93
101
  if (v.errors.length) {
94
102
  log.warn(`${v.errors.length} invalid line(s):`);
95
103
  v.errors.slice(0, 10).forEach((e) => log.dim(` line ${e.line}: ${e.reason}`));
@@ -105,6 +113,7 @@ async function createCmd(rest, ctx) {
105
113
  "js-render": { type: "boolean" },
106
114
  "premium-proxy": { type: "boolean" },
107
115
  "proxy-country": { type: "string" },
116
+ manual: { type: "boolean" },
108
117
  output: { type: "string" },
109
118
  follow: { type: "boolean" },
110
119
  wait: { type: "boolean" }, // back-compat alias for --follow
@@ -136,11 +145,6 @@ async function createCmd(rest, ctx) {
136
145
  // 1. every task URL must pass the allow/deny domain policy,
137
146
  // 2. the run must fit the batch-only page/credit caps (a batch is the one
138
147
  // primitive that fans out into many requests, so the caps bind here).
139
- const policy = loadPolicy();
140
- for (const job of v.jobs)
141
- assertDomainAllowed(job.url, policy);
142
- const est = estimateCredits(v.jobs);
143
- assertWithinLimits({ pages: v.validJobs, credits: est.credits }, policy, "batch");
144
148
  const jobParams = {};
145
149
  if (values["js-render"] === true)
146
150
  jobParams.js_render = true;
@@ -152,8 +156,14 @@ async function createCmd(rest, ctx) {
152
156
  const responseType = normalizeOutput(asString(values.output));
153
157
  if (responseType)
154
158
  jobParams.response_type = responseType;
159
+ const jobs = effectiveJobs(v.jobs, jobParams, values.manual === true);
160
+ const policy = loadPolicy();
161
+ for (const job of jobs)
162
+ assertDomainAllowed(job.url, policy);
163
+ const est = estimateCredits(jobs);
164
+ assertWithinLimits({ pages: v.validJobs, credits: est.credits }, policy, "batch");
155
165
  // toJobBody validates proxy_country/premium_proxy BEFORE any HTTP call.
156
- const body = toJobBody(v.jobs, jobParams);
166
+ const body = toJobBody(jobs, jobParams);
157
167
  const apiKey = await ensureApiKey(values["no-signup"] ? { ...policy, auto_signup: false } : policy, {
158
168
  onProvision: (a) => {
159
169
  log.info("No API key found — created a Zenrows Free plan account for you.");
@@ -2,7 +2,7 @@
2
2
  * `zenrows extract <url> [flags]` → Extract (structured extraction on `/v1/`).
3
3
  *
4
4
  * Default: `extract=auto` (site-tailored Extract, open beta). If the domain is
5
- * not enabled (AUTH010), falls back once to Autoparse. Opt into Autoparse
5
+ * not enabled or prepared (AUTH010 / REQS007), falls back once to Autoparse. Opt into Autoparse
6
6
  * directly with `--autoparse`. Other methods: `--css`, `--outputs`, `--output`.
7
7
  */
8
8
  import { ensureApiKey } from "../../core/ensure-key.js";
@@ -23,7 +23,7 @@ export const extract = {
23
23
  usage: "zenrows extract <url> [--autoparse | --css <json> | --outputs <filters> | --output md|text] [flags]",
24
24
  help: [
25
25
  "Methods:",
26
- " (default) extract=auto — site-tailored Extract (open beta); falls back to Autoparse if the domain is not enabled",
26
+ " (default) extract=auto — site-tailored Extract (open beta); falls back to Autoparse if the domain is not enabled or prepared",
27
27
  " --autoparse general-purpose Autoparse (any domain, no Extract fallback)",
28
28
  " --css <json> CSS selector map, e.g. '{\"title\":\"h1\",\"price\":\".price\"}'",
29
29
  " --outputs <list> built-in output filters → JSON: emails, phone_numbers, headings,",
@@ -119,7 +119,7 @@ export const extract = {
119
119
  const safeParams = { ...outcome.params };
120
120
  delete safeParams.apikey;
121
121
  if (outcome.fellBackToAutoparse) {
122
- log.info("Extract not enabled for this domain — fell back to Autoparse.");
122
+ log.info("Extract not available for this domain yet — fell back to Autoparse.");
123
123
  }
124
124
  const runDir = writeRun({
125
125
  runId,
@@ -2,6 +2,9 @@
2
2
  * Toolkit config (`.zenrows/config.json`). Non-secret, safe defaults.
3
3
  */
4
4
  import { randomUUID } from "node:crypto";
5
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { dirname, join } from "node:path";
5
8
  import { findWorkspace, readJson, workspacePaths, writeJson } from "./workspace.js";
6
9
  /** Confirmed Zenrows Fetch and Extract API base. */
7
10
  export const DEFAULT_API_BASE = "https://api.zenrows.com/v1/";
@@ -11,7 +14,7 @@ export const CONFIG_VERSION = "0.1.0";
11
14
  * entrypoint) so core modules — telemetry, signup provenance — can read it
12
15
  * without an import cycle. `VERSION` in `cli/index.ts` re-exports this.
13
16
  */
14
- export const CLI_VERSION = "1.3.1";
17
+ export const CLI_VERSION = "1.4.1";
15
18
  /** The User-Agent on every request to a Zenrows API. The gateway reads the version from it. */
16
19
  export const CLI_USER_AGENT = `zenrows-cli/${CLI_VERSION}`;
17
20
  /** Env var to override the Fetch and Extract API base (local/staging testing). */
@@ -63,21 +66,57 @@ export function saveConfig(config, projectRoot) {
63
66
  const ws = projectRoot ? workspacePaths(projectRoot) : (findWorkspace() ?? workspacePaths());
64
67
  writeJson(ws.config, config);
65
68
  }
69
+ /** Env var to relocate the per-machine config directory (tests, sandboxes). */
70
+ export const CONFIG_HOME_ENV = "ZENROWS_CONFIG_HOME";
71
+ /** Per-machine config directory, shared by every workspace on this machine. */
72
+ export function machineConfigDir() {
73
+ const override = process.env[CONFIG_HOME_ENV];
74
+ if (override && override.trim())
75
+ return override.trim();
76
+ if (process.platform === "win32" && process.env.APPDATA)
77
+ return join(process.env.APPDATA, "zenrows");
78
+ const xdg = process.env.XDG_CONFIG_HOME;
79
+ return join(xdg && xdg.trim() ? xdg.trim() : join(homedir(), ".config"), "zenrows");
80
+ }
81
+ function readMachineTelemetryId(file) {
82
+ try {
83
+ const id = readFileSync(file, "utf8").trim();
84
+ return id || undefined;
85
+ }
86
+ catch {
87
+ return undefined;
88
+ }
89
+ }
66
90
  /**
67
91
  * Return the stable anonymous agent id, generating + persisting one on first
68
92
  * use. Sent as the `X-ZR-Agent-Id` header on signup so the backend can correlate
69
93
  * the (anonymous) device with the account it later merges on claim. Contains no
70
94
  * PII — a random uuid only.
95
+ *
96
+ * Stored per machine, not per workspace: a per-workspace id made every new
97
+ * project folder look like a new device, so the backend could not tell a
98
+ * rate-limited retry from the machine that had just signed up. An id already in
99
+ * the workspace is adopted on first run so existing devices keep their identity.
100
+ * If the machine directory is not writable, falls back to the workspace config.
71
101
  */
72
102
  export function getOrCreateTelemetryId(projectRoot) {
103
+ const file = join(machineConfigDir(), "telemetry-id");
104
+ const machineId = readMachineTelemetryId(file);
105
+ if (machineId)
106
+ return machineId;
73
107
  const ws = projectRoot ? workspacePaths(projectRoot) : (findWorkspace() ?? workspacePaths());
74
108
  // Merge onto the raw stored config (not loadConfig) so we never persist the
75
109
  // ZENROWS_API_BASE env override back into config.json.
76
110
  const stored = readJson(ws.config) ?? {};
77
- const existing = stored.telemetryId;
78
- if (existing && existing.trim())
79
- return existing;
80
- const id = randomUUID();
81
- saveConfig({ ...defaultConfig(), ...stored, telemetryId: id }, projectRoot);
111
+ const workspaceId = stored.telemetryId?.trim() ? stored.telemetryId.trim() : undefined;
112
+ const id = workspaceId ?? randomUUID();
113
+ try {
114
+ mkdirSync(dirname(file), { recursive: true });
115
+ writeFileSync(file, `${id}\n`, { mode: 0o600 });
116
+ }
117
+ catch {
118
+ if (!workspaceId)
119
+ saveConfig({ ...defaultConfig(), ...stored, telemetryId: id }, projectRoot);
120
+ }
82
121
  return id;
83
122
  }
@@ -98,6 +98,18 @@ export async function scrape(apiBase, apiKey, params, opts = {}) {
98
98
  concurrencyRemaining: numOrNull(res.headers.get("concurrency-remaining")),
99
99
  redactedUrl: redacted,
100
100
  };
101
+ // REQS007 on extract=auto: the domain hasn't been prepared for Extract yet.
102
+ // Same recovery as AUTH010 (the extract adapter falls back to autoparse), so it
103
+ // maps to the same code. Checked before the 403 auth branch below.
104
+ if (res.status === 403 && params.extract !== undefined && zrErrorCode(body) === "REQS007") {
105
+ throw new ToolkitError({
106
+ code: "EXTRACT_DOMAIN_NOT_ENABLED",
107
+ message: "Extract has not been prepared for this domain yet.",
108
+ likely_cause: zrErrorDetail(body) ?? "This domain is not prepared for Extract.",
109
+ next_action: "Retry with --autoparse for general-purpose extraction on any site.",
110
+ suggested_commands: [`zenrows extract ${params.url} --autoparse`],
111
+ });
112
+ }
101
113
  if (res.status === 401 || res.status === 403) {
102
114
  // Distinguish Zenrows auth errors from target-site 403s. Zenrows auth
103
115
  // failures surface AUTH00x codes in the body.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenrows/cli",
3
- "version": "1.3.1",
3
+ "version": "1.4.1",
4
4
  "description": "Open-source CLI, MCP, skills, templates, workflows, recipes, and evals layer for giving AI agents reliable access to protected web data through Zenrows cloud infrastructure.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -35,14 +35,15 @@ structured data instead of raw HTML.
35
35
  {"url": "https://example.com/products", "extract": "auto", "external_id": "p1"}
36
36
  ```
37
37
 
38
- An Extract task costs the same as a regular one (1 credit at base tier), so
39
- `batch estimate` prices it correctly. The result carries two keys: `html` (the
38
+ An Extract task costs the same as a regular one, so `batch estimate` prices it
39
+ correctly. The result carries two keys: `html` (the
40
40
  raw page) and `parsed` (the structured data). Validate on a single URL with
41
41
  `zenrows extract <url>` before running the full batch.
42
42
 
43
43
  ## Cloud (needs a key + beta access)
44
44
  ```
45
- zenrows batch create jobs.jsonl [--js-render] [--premium-proxy] [--proxy-country cc] [--wait]
45
+ zenrows batch create jobs.jsonl [--proxy-country cc] [--wait]
46
+ zenrows batch create jobs.jsonl --manual [--js-render] [--premium-proxy]
46
47
  zenrows batch status <id> # run status + stats
47
48
  zenrows batch results <id> [--status successful|failed|all] [--out results.jsonl]
48
49
  zenrows batch wait <id> [--timeout ms]
@@ -50,9 +51,17 @@ zenrows batch cancel <id> # stop an in-flight run
50
51
  zenrows batch retry-failed <id> # rerun only the failed tasks (new run)
51
52
  ```
52
53
 
53
- `create` validates the spec first, then submits. `--proxy-country` needs
54
- `--premium-proxy` (or `mode=auto`) and is rejected before any request. Job-level
55
- flags apply to every task; per-task keys in the JSONL override them.
54
+ Every task runs in Adaptive Stealth Mode (`mode=auto`) by default, like `fetch`
55
+ and `extract`: Zenrows enables JS rendering and premium proxies only on the pages
56
+ that need them, and bills only the configuration that succeeds. A task that sets
57
+ `js_render`, `premium_proxy` or `mode` itself keeps its own settings, and
58
+ `--js-render` / `--premium-proxy` force that configuration on every task instead.
59
+ `--manual` turns Adaptive Stealth Mode off. `batch estimate` counts auto tasks at
60
+ the 25x upper bound, so it's the most the run can cost, not the expected cost.
61
+
62
+ `create` validates the spec first, then submits. Outside Adaptive Stealth Mode,
63
+ `--proxy-country` needs `--premium-proxy` and is rejected before any request.
64
+ Job-level flags apply to every task; per-task keys in the JSONL override them.
56
65
 
57
66
  A run ends in one of `completed`, `failed`, `stopped`, or `deleted`. `status`,
58
67
  `wait`, and `create --wait` exit non-zero (`ok: false` under `--json`) only for
@@ -29,7 +29,9 @@ zenrows batch retry-failed <job-id> # rerun only the failed
29
29
  zenrows batch cancel <job-id> # stop an in-flight run
30
30
  ```
31
31
 
32
- Job-level scrape options come from flags (`--js-render`, `--premium-proxy`,
33
- `--proxy-country`, `--output`); per-line keys override them for that URL.
32
+ Tasks use Adaptive Stealth Mode (`mode=auto`) by default. Job-level scrape
33
+ options come from flags (`--js-render`, `--premium-proxy`, `--proxy-country`,
34
+ `--output`, or `--manual` to turn Adaptive Stealth Mode off); per-line keys
35
+ override them for that URL.
34
36
  Without beta access yet, validate/estimate locally or fan out with
35
37
  `zenrows fetch` per URL on a small sample first.