@zenrows/cli 1.9.0 → 1.11.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
@@ -83,6 +83,18 @@ unclaimed Zenrows Free plan account**, stores the key in `.zenrows/secrets.json`
83
83
  (0600, gitignored), and records the account in `.zenrows/account.json`
84
84
  (no secret — just the accountId, Free-period info, and a claim link).
85
85
 
86
+ Zenrows may first ask the agent one short question before it creates the
87
+ account. The command then fails with `SIGNUP_CHALLENGE` and prints the
88
+ question; answer it and re-run the original command:
89
+
90
+ ```bash
91
+ zenrows signup --answer "<your answer>"
92
+ ```
93
+
94
+ A wrong or late answer gets a new question. Too many questions or wrong
95
+ answers from one network pause signups there for an hour
96
+ (`SIGNUP_CHALLENGE_LOCKED`).
97
+
86
98
  When you (or your agent) want to keep the usage and history, **claim** the
87
99
  account — this confirms an email (Google, GitHub or a 6-digit code); the same
88
100
  key keeps working:
@@ -96,10 +108,11 @@ zenrows usage # plan usage, credits & concurrency (does not count again
96
108
  Opt out of auto-provisioning at any time:
97
109
 
98
110
  ```bash
99
- zenrows fetch <url> --no-signup # do not auto-create an account for this call
100
- zenrows signup --agent # explicitly provision a Free plan account, headless
101
- zenrows login --api-key <key> # use an existing key (stored 0600, gitignored)
102
- zenrows login --env # use the ZENROWS_API_KEY environment variable
111
+ zenrows fetch <url> --no-signup # do not auto-create an account for this call
112
+ zenrows signup --agent # explicitly provision a Free plan account, headless
113
+ zenrows signup --answer "<text>" # answer the signup question (SIGNUP_CHALLENGE)
114
+ zenrows login --api-key <key> # use an existing key (stored 0600, gitignored)
115
+ zenrows login --env # use the ZENROWS_API_KEY environment variable
103
116
  ```
104
117
 
105
118
  Set `auto_signup: false` in `.zenrows/policy.json` to disable auto-provisioning
@@ -134,10 +147,18 @@ few minutes if it should work, or use `--css`; preparing again does not change
134
147
  it. `zenrows extract` never falls back to Autoparse. The `fallbackAutoparse`
135
148
  adapter option is deprecated and has no effect.
136
149
 
150
+ Pass 1 to 5 example URLs. One works; we recommend 3 or more of the same page
151
+ type (e.g. 3 product pages), because Extract compares pages of one type to check
152
+ the fields it found and fill in fields one page is missing. Several page types
153
+ can share the 5 (e.g. 3 product pages + 2 search-result pages).
154
+
137
155
  ```bash
138
- zenrows extract prepare example.com --url https://example.com/p/1 --url https://example.com/list --wait
156
+ zenrows extract prepare example.com \
157
+ --url https://www.example.com/products/a \
158
+ --url https://www.example.com/products/b \
159
+ --url https://www.example.com/products/c --wait
139
160
  zenrows extract prepare status example.com # progress + monthly allowance
140
- zenrows extract https://example.com/p/1 # then extract again
161
+ zenrows extract https://www.example.com/products/a # then extract again
141
162
  ```
142
163
 
143
164
  ## 9. Batch
@@ -62,7 +62,7 @@ Use MCP tool names — not CLI `zenrows fetch`:
62
62
  | --- | --- |
63
63
  | `zenrows` | Decision tree across all primitives |
64
64
  | `protected-fetch` | `scrape` |
65
- | `extract` | `extract` (fallback: `scrape` + css_extractor); on REQS007 with reason `domain_not_prepared`, `prepare_domain` then `prepared_domain_status` |
65
+ | `extract` | `extract` (fallback: `scrape` + css_extractor); on REQS007 with reason `domain_not_prepared`, `prepare_domain` (1 to 5 example URLs; we recommend 3 or more of the same page type) then `prepared_domain_status` |
66
66
  | `batch-jobs` | `batch_create`, `batch_status`, `batch_results`, `batch_cancel` |
67
67
  | `interact-browser` | `browser_*` (escalation only) |
68
68
  | `cost-control` | Cost multipliers + escalate-with-evidence |
@@ -48,15 +48,20 @@ Preparing a domain:
48
48
 
49
49
  ```
50
50
  extract({ url }) # 1. REQS007, reason domain_not_prepared
51
- prepare_domain({ domain: "example.com",
52
- example_urls: ["https://example.com/p/1", "https://example.com/list"] })
51
+ prepare_domain({ domain: "example.com", example_urls: [
52
+ "https://www.example.com/products/a",
53
+ "https://www.example.com/products/b",
54
+ "https://www.example.com/products/c"] })
53
55
  # 2. returns at once, status "preparing"
54
56
  prepared_domain_status({ domain: "example.com" }) # 3. about every 30 seconds until "ready"
55
57
  extract({ url }) # 4. extract again
56
58
  ```
57
59
 
58
- - `example_urls`: 1 to 5 real pages you want to extract, one per page kind
59
- (for example a product page and a listing page).
60
+ - `example_urls`: 1 to 5 real pages you want to extract. One works; we
61
+ recommend 3 or more of the same page type (e.g. 3 product pages), because
62
+ Extract compares pages of one type to check the fields it found and fill in
63
+ fields one page is missing. Several page types can share the 5 (e.g. 3
64
+ product pages + 2 search-result pages).
60
65
  - The domain is the host: `shop.example.com` and `example.com` are prepared
61
66
  separately, and a leading `www.` is dropped.
62
67
  - Preparing takes a few minutes and costs no credits. Each new domain uses one
@@ -27,8 +27,10 @@ cannot handle. **Escalation only.**
27
27
  6. Always `browser_close` when done
28
28
 
29
29
  Other useful tools: `browser_evaluate`, cookies (`browser_get_cookies` /
30
- `browser_set_cookies` / `browser_clear_cookies`), `browser_local_storage`,
31
- tabs (`browser_new_tab` / `browser_switch_tab`), `browser_generate_pdf`.
30
+ `browser_set_cookies` / `browser_clear_cookies`), local storage
31
+ (`browser_get_local_storage` / `browser_set_local_storage` /
32
+ `browser_clear_local_storage`), tabs (`browser_new_tab` / `browser_switch_tab`),
33
+ `browser_generate_pdf`.
32
34
 
33
35
  ## Rules
34
36
 
@@ -20,7 +20,10 @@ You do **not** need an API key in the plugin package.
20
20
 
21
21
  - **stdio** (`npx -y @zenrows/mcp`): on first cloud call the server can
22
22
  auto-create a **free, unclaimed** Zenrows Free plan account and persist the
23
- key locally. When the Free plan nears its limit (or the user asks), surface
23
+ key locally. Zenrows may first ask one short question (which word is a
24
+ bird, which word does not belong): the tool call
25
+ returns it; answer it yourself with the `account_signup_answer` tool, then
26
+ retry the call. When the Free plan nears its limit (or the user asks), surface
24
27
  the **claim URL** so a human can claim the account (confirming an email
25
28
  with Google, GitHub or a 6-digit code). Claim links expire after 30 days. The
26
29
  same key keeps working after claim. Never handle payment or upgrades —
@@ -68,7 +71,7 @@ If the user needs login, clicks, forms, sessions, or persistent state:
68
71
  | --- | --- | --- |
69
72
  | Protected Fetch | `scrape` | Default markdown; HTML / plaintext / PDF / screenshot options |
70
73
  | Extract | `extract` | Structured JSON (`extract=auto` on any prepared domain, css_extractor; autoparse deprecated). `extract=auto` is currently free; billing may apply later |
71
- | Prepare a domain | `prepare_domain`, `prepared_domain_status` | After an `extract` REQS007 with reason `domain_not_prepared`: prepare the domain with 1 to 5 example URLs, poll about every 30 seconds until ready, extract again. No credits; uses one monthly allowance slot. Not for `page_not_recognised`: that domain is already prepared |
74
+ | Prepare a domain | `prepare_domain`, `prepared_domain_status` | After an `extract` REQS007 with reason `domain_not_prepared`: prepare the domain with 1 to 5 example URLs (one works; we recommend 3 or more of the same page type, because Extract compares pages of one type to check the fields it found and fill in fields one page is missing), poll about every 30 seconds until ready, extract again. No credits; uses one monthly allowance slot. Not for `page_not_recognised`: that domain is already prepared |
72
75
  | Batch | `batch_create`, `batch_status`, `batch_results`, `batch_cancel` | Same credits as Fetch; a 403 maps to `BATCH_ACCESS_DENIED` |
73
76
  | Browser | `browser_*` (start with `browser_navigate`) | Escalation-only; same backend as Browser Sessions |
74
77
 
@@ -5,14 +5,15 @@
5
5
  * The way out of REQS007: prepare the domain so `extract=auto` works on it.
6
6
  * Submitting returns at once; `--wait` polls until the preparation is ready or
7
7
  * failed (a few minutes). Costs no credits; each new domain uses one slot of the
8
- * account's monthly allowance.
8
+ * account's monthly allowance. One --url works; with fewer than 3 the command prints a
9
+ * tip to stderr (never an error): we recommend 3 or more of the same page type, up to 5.
9
10
  */
10
11
  import { ensureApiKey } from "../../core/ensure-key.js";
11
12
  import { loadConfig } from "../../core/config.js";
12
13
  import { loadPolicy } from "../../core/policy.js";
13
14
  import { log } from "../../core/logger.js";
14
15
  import { ToolkitError } from "../../core/errors.js";
15
- import { DEFAULT_WAIT_TIMEOUT_MS, MAX_EXAMPLE_URLS, failureNextStep, getAllowance, getPreparedDomain, normalizeDomain, prepareDomain, statusNextStep, waitForPreparedDomain, } from "../../core/prepared-domains-api.js";
16
+ import { DEFAULT_WAIT_TIMEOUT_MS, MAX_EXAMPLE_URLS, RECOMMENDED_EXAMPLE_URLS, exampleUrlsHint, failureNextStep, getAllowance, getPreparedDomain, normalizeDomain, prepareDomain, statusNextStep, waitForPreparedDomain, } from "../../core/prepared-domains-api.js";
16
17
  import { parse, asString } from "../command.js";
17
18
  import { printError } from "../output.js";
18
19
  export const PREPARE_USAGE = "zenrows extract prepare <domain> --url <page-url> [--url <page-url>...] [--wait [--timeout <seconds>]]\n" +
@@ -23,7 +24,8 @@ export const PREPARE_HELP = [
23
24
  "Not for EXTRACT_PAGE_NOT_RECOGNISED: that domain is already prepared, and preparing it",
24
25
  "again does not change that answer.",
25
26
  "",
26
- ` --url <page-url> 1 to ${MAX_EXAMPLE_URLS} real pages you want to extract, one per page kind (repeat --url)`,
27
+ ` --url <page-url> a real page you want to extract (repeat --url, 1 to ${MAX_EXAMPLE_URLS}). One works;`,
28
+ ` we recommend ${RECOMMENDED_EXAMPLE_URLS} or more of the same page type (e.g. 3 product pages)`,
27
29
  " --wait poll every 30 seconds until the domain is ready or failed; off by default",
28
30
  ` --timeout <seconds> give up waiting after this long (default ${DEFAULT_WAIT_TIMEOUT_MS / 1000})`,
29
31
  " status <domain> show progress and the account's monthly allowance",
@@ -33,6 +35,14 @@ export const PREPARE_HELP = [
33
35
  "account's monthly allowance; a domain that is already prepared answers ready at once and",
34
36
  "uses none. The domain is the host (shop.example.com and example.com are separate); a",
35
37
  "leading www. is dropped.",
38
+ "",
39
+ `1 to ${MAX_EXAMPLE_URLS} example URLs. One works; we recommend ${RECOMMENDED_EXAMPLE_URLS} or more of the same page type`,
40
+ "(e.g. 3 product pages), because Extract compares pages of one type to check the fields",
41
+ `it found and fill in fields one page is missing. Several page types can share the ${MAX_EXAMPLE_URLS}`,
42
+ "(e.g. 3 product pages + 2 search-result pages). Three product pages:",
43
+ "",
44
+ " zenrows extract prepare example.com --url https://www.example.com/products/a \\",
45
+ " --url https://www.example.com/products/b --url https://www.example.com/products/c --wait",
36
46
  ].join("\n");
37
47
  export async function runPrepare(argv, ctx, deps = {}) {
38
48
  const isStatus = argv[0] === "status";
@@ -69,6 +79,10 @@ export async function runPrepare(argv, ctx, deps = {}) {
69
79
  return 0;
70
80
  }
71
81
  log.step(`Prepare ${domain} for Extract (${urls.length} example URL${urls.length === 1 ? "" : "s"})…`);
82
+ // Advice on stderr only: the --json payload on stdout and the exit code are unchanged.
83
+ const hint = exampleUrlsHint(urls.length);
84
+ if (hint)
85
+ log.dim(hint);
72
86
  let record = await prepareDomain(domain, urls, opts);
73
87
  if (record.already_in_progress)
74
88
  log.info(`${domain} was already being prepared; following that preparation.`);
@@ -108,7 +122,7 @@ function validateUrls(raw, domain) {
108
122
  code: "INVALID_USAGE",
109
123
  message: `Pass 1 to ${MAX_EXAMPLE_URLS} example page URLs with --url (got ${urls.length}).`,
110
124
  likely_cause: "Preparation learns the domain from real pages you want to extract.",
111
- next_action: "Add one --url per page kind, e.g. a product page and a listing page.",
125
+ next_action: `Add 1 to ${MAX_EXAMPLE_URLS} --url pages. One works; ${RECOMMENDED_EXAMPLE_URLS} or more of the same page type work better.`,
112
126
  suggested_commands: [`zenrows extract prepare ${domain} --url https://${domain}/<page>`],
113
127
  });
114
128
  }
@@ -4,9 +4,13 @@
4
4
  * There is no documented programmatic signup API, so we never fake account
5
5
  * creation: we explain what is needed, open the signup URL, and hand off to
6
6
  * `login`.
7
+ *
8
+ * `--agent` provisions a Free plan account headless, and `--answer` answers the
9
+ * one short question signup may ask an agent first (SIGNUP_CHALLENGE).
7
10
  */
8
- import { saveApiKey } from "../../core/auth.js";
9
- import { readAccount, signupAgent, writeAccount } from "../../core/agent-account.js";
11
+ import { answerSignupChallenge, readAccount, signupAgent } from "../../core/agent-account.js";
12
+ import { storeSignup } from "../../core/ensure-key.js";
13
+ import { ToolkitError } from "../../core/errors.js";
10
14
  import { attributionEnabled } from "../../core/config.js";
11
15
  import { log } from "../../core/logger.js";
12
16
  import { openUrl, SIGNUP_URL, withParams } from "../../core/open-url.js";
@@ -15,9 +19,42 @@ import { parse } from "../command.js";
15
19
  export const signup = {
16
20
  name: "signup",
17
21
  summary: "Create a Zenrows account / obtain an API key (opens the browser).",
18
- usage: "zenrows signup [--agent] [--no-open]",
22
+ usage: 'zenrows signup [--agent] [--no-open] [--answer "<text>"]',
23
+ help: [
24
+ "Without flags, opens the Zenrows signup page in the browser.",
25
+ "",
26
+ " --agent Create a Free plan account headless and store its key.",
27
+ " --no-open Print the signup URL instead of opening it.",
28
+ ' --answer "<text>" Answer the question Zenrows asked before creating an account',
29
+ " (error SIGNUP_CHALLENGE). Answer it yourself, then re-run",
30
+ " your original command.",
31
+ ].join("\n"),
19
32
  async run(argv, ctx) {
20
- const { values } = parse(argv, { "no-open": { type: "boolean" }, agent: { type: "boolean" } });
33
+ const { values } = parse(argv, {
34
+ "no-open": { type: "boolean" },
35
+ agent: { type: "boolean" },
36
+ answer: { type: "string" },
37
+ });
38
+ if (values.answer !== undefined) {
39
+ const answer = String(values.answer).trim();
40
+ if (!answer) {
41
+ throw new ToolkitError({
42
+ code: "INVALID_USAGE",
43
+ message: "--answer needs the answer text.",
44
+ likely_cause: "An empty value was passed to --answer.",
45
+ next_action: 'Run: zenrows signup --answer "<your answer>"',
46
+ });
47
+ }
48
+ const res = await answerSignupChallenge(answer);
49
+ const account = storeSignup(res);
50
+ log.success("Right answer. Created a Zenrows Free plan account and stored its API key.");
51
+ log.dim(`Claim it anytime (keeps your usage): ${account.claimUrl}`);
52
+ log.info("Now re-run your original command.");
53
+ if (ctx.json) {
54
+ log.out(JSON.stringify({ ok: true, accountId: res.accountId, claimUrl: res.claimUrl, next_action: "Re-run your original command." }, null, 2));
55
+ }
56
+ return 0;
57
+ }
21
58
  if (values.agent) {
22
59
  const account = readAccount();
23
60
  if (account) {
@@ -25,9 +62,7 @@ export const signup = {
25
62
  return 0;
26
63
  }
27
64
  const res = await signupAgent();
28
- saveApiKey(res.apiKey);
29
- const now = new Date().toISOString();
30
- writeAccount({ accountId: res.accountId, unclaimed: true, claimUrl: res.claimUrl, createdAt: now, claimUrlIssuedAt: now });
65
+ storeSignup(res);
31
66
  log.success("Created a Zenrows Free plan account.");
32
67
  log.dim(`Claim it anytime: ${res.claimUrl}`);
33
68
  if (ctx.json)
@@ -12,6 +12,7 @@ import { ToolkitError } from "./errors.js";
12
12
  import { AGENT_SIGNUP_API_URL, WELL_KNOWN_PROTECTED_RESOURCE } from "./open-url.js";
13
13
  import { detectClient } from "./provenance.js";
14
14
  import { findWorkspace, readJson, workspacePaths, writePrivateJson } from "./workspace.js";
15
+ import { challengeError, challengeExpired, challengeLockedError, clearChallenge, isChallengeLocked, noChallengeError, parseChallenge, readChallenge, wasWrongAnswer, writeChallenge, } from "./signup-challenge.js";
15
16
  /** Env var to override the signup endpoint (highest priority; for local testing). */
16
17
  export const SIGNUP_URL_ENV = "ZENROWS_AGENT_SIGNUP_URL";
17
18
  /** Env var to override the discovery base origin (for local testing). */
@@ -117,8 +118,25 @@ export function clearAccount(projectRoot) {
117
118
  return true;
118
119
  }
119
120
  export async function signupAgent(opts = {}) {
120
- const urls = opts.url ? [opts.url] : await signupCandidates(undefined, { fetchImpl: opts.fetchImpl });
121
- const doFetch = opts.fetchImpl ?? fetch;
121
+ const urls = opts.url ? [opts.url] : await signupCandidates(opts.projectRoot, { fetchImpl: opts.fetchImpl });
122
+ return postSignup(urls, undefined, opts);
123
+ }
124
+ /**
125
+ * Answer the pending signup question (see signup-challenge.ts). Posts it to
126
+ * the signup URL that asked it; a right answer creates the account like a
127
+ * plain signup does. A wrong answer stores and reports the new question.
128
+ */
129
+ export async function answerSignupChallenge(answer, opts = {}) {
130
+ const pending = readChallenge(opts.projectRoot);
131
+ if (!pending)
132
+ throw noChallengeError(false);
133
+ if (challengeExpired(pending)) {
134
+ clearChallenge(opts.projectRoot);
135
+ throw noChallengeError(true);
136
+ }
137
+ return postSignup([pending.signupUrl], { challenge_id: pending.id, answer }, opts);
138
+ }
139
+ function signupHeaders(projectRoot) {
122
140
  // Signup is the one functional request the CLI must make — no separate
123
141
  // telemetry beacon exists. When attribution is enabled we ride anonymous
124
142
  // provenance on it (a random id + coarse host/runtime brand). No PII, no URLs.
@@ -126,7 +144,7 @@ export async function signupAgent(opts = {}) {
126
144
  const headers = {
127
145
  "content-type": "application/json",
128
146
  "User-Agent": CLI_USER_AGENT,
129
- ...agentClientHeader(),
147
+ ...agentClientHeader({ projectRoot }),
130
148
  };
131
149
  if (attributionEnabled()) {
132
150
  const p = detectClient();
@@ -138,14 +156,21 @@ export async function signupAgent(opts = {}) {
138
156
  headers["X-ZR-Node"] = p.node;
139
157
  headers["X-ZR-CI"] = p.ci ? "1" : "0";
140
158
  }
159
+ return headers;
160
+ }
161
+ async function postSignup(urls, body, opts) {
162
+ const doFetch = opts.fetchImpl ?? fetch;
163
+ const headers = signupHeaders(opts.projectRoot);
141
164
  // Try each candidate in order; on any failure fall back to the next (the last
142
165
  // is always the built-in default). This is the safety net: a wrong/blocked
143
166
  // discovered endpoint can never leave provisioning worse off than no discovery.
167
+ // A signup question or the wrong-answer lock is a real answer from a working
168
+ // endpoint, so it ends the loop.
144
169
  let lastErr;
145
170
  for (const url of urls) {
146
171
  let res;
147
172
  try {
148
- res = await doFetch(url, { method: "POST", headers });
173
+ res = await doFetch(url, { method: "POST", headers, ...(body ? { body: JSON.stringify(body) } : {}) });
149
174
  }
150
175
  catch (err) {
151
176
  lastErr = new ToolkitError({
@@ -156,9 +181,24 @@ export async function signupAgent(opts = {}) {
156
181
  });
157
182
  continue;
158
183
  }
159
- if (res.status === 201)
184
+ if (res.status === 201) {
185
+ clearChallenge(opts.projectRoot);
160
186
  return (await res.json());
161
- lastErr = signupError(res.status, await res.text());
187
+ }
188
+ const text = await res.text();
189
+ if (res.status === 428) {
190
+ const challenge = parseChallenge(text, url);
191
+ if (challenge) {
192
+ writeChallenge(challenge, opts.projectRoot);
193
+ throw challengeError(challenge, { wrong: body !== undefined || wasWrongAnswer(text) });
194
+ }
195
+ }
196
+ if (res.status === 429 && isChallengeLocked(text)) {
197
+ clearChallenge(opts.projectRoot);
198
+ const ra = Number(res.headers.get("retry-after"));
199
+ throw challengeLockedError(Number.isFinite(ra) && ra > 0 ? ra : undefined);
200
+ }
201
+ lastErr = signupError(res.status, text);
162
202
  }
163
203
  throw (lastErr ??
164
204
  new ToolkitError({
@@ -14,7 +14,7 @@ export const CONFIG_VERSION = "0.1.0";
14
14
  * entrypoint) so core modules — telemetry, signup provenance — can read it
15
15
  * without an import cycle. `VERSION` in `cli/index.ts` re-exports this.
16
16
  */
17
- export const CLI_VERSION = "1.9.0"; // x-release-please-version
17
+ export const CLI_VERSION = "1.11.0"; // x-release-please-version
18
18
  /** The User-Agent on every request to a Zenrows API. The gateway reads the version from it. */
19
19
  export const CLI_USER_AGENT = `zenrows-cli/${CLI_VERSION}`;
20
20
  /** Env var to override the Fetch and Extract API base (local/staging testing). */
@@ -7,8 +7,15 @@ export async function ensureApiKey(policy, opts = {}) {
7
7
  if (!policy.auto_signup)
8
8
  return requireApiKey(opts.projectRoot); // throws AUTH_MISSING
9
9
  const doSignup = opts.signupImpl ?? signupAgent;
10
- const res = await doSignup();
11
- saveApiKey(res.apiKey, opts.projectRoot);
10
+ // May throw SIGNUP_CHALLENGE: the agent answers with `zenrows signup --answer`.
11
+ const res = await doSignup({ projectRoot: opts.projectRoot });
12
+ const account = storeSignup(res, opts.projectRoot);
13
+ opts.onProvision?.(account);
14
+ return res.apiKey;
15
+ }
16
+ /** Persist a fresh agent signup: the key (0600) and the account metadata. */
17
+ export function storeSignup(res, projectRoot) {
18
+ saveApiKey(res.apiKey, projectRoot);
12
19
  const account = {
13
20
  accountId: res.accountId,
14
21
  unclaimed: true,
@@ -16,7 +23,6 @@ export async function ensureApiKey(policy, opts = {}) {
16
23
  createdAt: new Date().toISOString(),
17
24
  };
18
25
  account.claimUrlIssuedAt = account.createdAt;
19
- writeAccount(account, opts.projectRoot);
20
- opts.onProvision?.(account);
21
- return res.apiKey;
26
+ writeAccount(account, projectRoot);
27
+ return account;
22
28
  }
@@ -16,6 +16,7 @@ export class ToolkitError extends Error {
16
16
  job_id;
17
17
  idempotency_key;
18
18
  retry_after;
19
+ challenge;
19
20
  constructor(shape) {
20
21
  super(shape.message);
21
22
  this.name = "ToolkitError";
@@ -28,6 +29,7 @@ export class ToolkitError extends Error {
28
29
  this.job_id = shape.job_id;
29
30
  this.idempotency_key = shape.idempotency_key;
30
31
  this.retry_after = shape.retry_after;
32
+ this.challenge = shape.challenge;
31
33
  }
32
34
  toJSON() {
33
35
  return {
@@ -41,6 +43,7 @@ export class ToolkitError extends Error {
41
43
  ...(this.job_id ? { job_id: this.job_id } : {}),
42
44
  ...(this.idempotency_key ? { idempotency_key: this.idempotency_key } : {}),
43
45
  ...(this.retry_after !== undefined ? { retry_after: this.retry_after } : {}),
46
+ ...(this.challenge ? { challenge: this.challenge } : {}),
44
47
  };
45
48
  }
46
49
  }
@@ -314,7 +314,7 @@ export function extractDomainNotPrepared(url, body) {
314
314
  code: "EXTRACT_DOMAIN_NOT_PREPARED",
315
315
  message: `Extract hasn't prepared ${domain} yet. Prepare it with \`${prepare}\` (a few example URLs, a few minutes, no credits), then retry; meanwhile use --css.`,
316
316
  likely_cause: detail ?? `Extract has not prepared ${domain} yet.`,
317
- next_action: `Run \`${prepare} --wait\` (up to 5 --url pages, one per page kind you want), then retry \`zenrows extract ${url}\`. Without --wait, check progress about every 30 seconds with \`zenrows extract prepare status ${domain}\`. Meanwhile extract with --css and explicit selectors.`,
317
+ next_action: `Run \`${prepare} --wait\` (1 to 5 --url pages: one works; we recommend 3 or more of the same page type), then retry \`zenrows extract ${url}\`. Without --wait, check progress about every 30 seconds with \`zenrows extract prepare status ${domain}\`. Meanwhile extract with --css and explicit selectors.`,
318
318
  suggested_commands: [`${prepare} --wait`, `zenrows extract prepare status ${domain}`, css],
319
319
  server_code: "REQS007",
320
320
  });
@@ -4,7 +4,8 @@
4
4
  * `extract=auto` answers REQS007 on a domain Extract has not prepared yet. Preparing
5
5
  * it is a call any key can make: `POST {apiBase}/extract/prepared-domains` with the
6
6
  * domain and 1 to 5 example URLs answers 202 at once; the preparation itself takes a
7
- * few minutes and costs no credits. `GET .../{domain}` reports progress and
7
+ * few minutes and costs no credits. One example URL works; we recommend 3 or more of
8
+ * the same page type (see `RECOMMENDED_EXAMPLE_URLS`). `GET .../{domain}` reports progress and
8
9
  * `GET .../allowance` the account's monthly allowance of new domains. Auth is the
9
10
  * `X-API-Key` header; errors are problem+json and we branch on `status` + `code`.
10
11
  * `fetchImpl` / `sleepImpl` / `now` are injectable for tests.
@@ -14,6 +15,23 @@ import { registerSecret } from "./logger.js";
14
15
  import { CLI_USER_AGENT, loadConfig } from "./config.js";
15
16
  import { agentClientHeader } from "./agent-client.js";
16
17
  export const MAX_EXAMPLE_URLS = 5;
18
+ /**
19
+ * How many example URLs to recommend: 3 or more of the same page type, because
20
+ * Extract compares pages of one type to check the fields it found and fill in fields
21
+ * one page is missing. Advice only: 1 and 2 URLs are still valid.
22
+ */
23
+ export const RECOMMENDED_EXAMPLE_URLS = 3;
24
+ /**
25
+ * A short, non-blocking hint when fewer than `RECOMMENDED_EXAMPLE_URLS` example
26
+ * URLs were given; undefined otherwise. Never an error.
27
+ */
28
+ export function exampleUrlsHint(count) {
29
+ if (count >= RECOMMENDED_EXAMPLE_URLS)
30
+ return undefined;
31
+ return (`Tip: ${count === 1 ? "one example URL works" : `${count} example URLs work`}; we recommend ` +
32
+ `${RECOMMENDED_EXAMPLE_URLS} or more of the same page type (e.g. 3 product pages), up to ${MAX_EXAMPLE_URLS} in total, ` +
33
+ "because Extract compares pages of one type to check the fields it found and fill in fields one page is missing.");
34
+ }
17
35
  /** How often `--wait` polls, and for how long by default. */
18
36
  export const DEFAULT_POLL_INTERVAL_MS = 30_000;
19
37
  export const DEFAULT_WAIT_TIMEOUT_MS = 15 * 60_000;
@@ -247,7 +265,7 @@ export function failureNextStep(domain, failure) {
247
265
  step = `Run the same zenrows extract prepare ${domain} command again.`;
248
266
  break;
249
267
  case "discovery_failed":
250
- step = `Run zenrows extract prepare ${domain} again with 1 to ${MAX_EXAMPLE_URLS} --url page URLs.`;
268
+ step = `Run zenrows extract prepare ${domain} again with 1 to ${MAX_EXAMPLE_URLS} --url page URLs; we recommend ${RECOMMENDED_EXAMPLE_URLS} or more of the same page type (e.g. 3 product pages).`;
251
269
  break;
252
270
  case "unpreparable":
253
271
  step = `Run zenrows extract prepare ${domain} again with different --url pages from the same site; the same URLs fail again.`;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The signup challenge: before it creates an account, the signup endpoint may
3
+ * answer 428 with one short natural-language question for the agent running
4
+ * the CLI to answer itself. The CLI never tries to solve it.
5
+ *
6
+ * Each CLI run is its own process, so the pending question is kept in
7
+ * `.zenrows/signup-challenge.json` (no secret: the id, the question, when it
8
+ * expires, and the signup URL that issued it, which is where the answer goes).
9
+ */
10
+ import { existsSync, unlinkSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { ToolkitError } from "./errors.js";
13
+ import { findWorkspace, readJson, workspacePaths, writeJson } from "./workspace.js";
14
+ /** The command an agent runs to answer the pending question. */
15
+ export const ANSWER_COMMAND = 'zenrows signup --answer "<your answer>"';
16
+ export function challengePath(projectRoot) {
17
+ const ws = projectRoot ? workspacePaths(projectRoot) : (findWorkspace() ?? workspacePaths());
18
+ return join(ws.dir, "signup-challenge.json");
19
+ }
20
+ export function readChallenge(projectRoot) {
21
+ try {
22
+ const c = readJson(challengePath(projectRoot));
23
+ if (!c || typeof c.id !== "string" || typeof c.prompt !== "string" || typeof c.signupUrl !== "string")
24
+ return null;
25
+ return c;
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ export function writeChallenge(c, projectRoot) {
32
+ writeJson(challengePath(projectRoot), c);
33
+ }
34
+ export function clearChallenge(projectRoot) {
35
+ const file = challengePath(projectRoot);
36
+ if (!existsSync(file))
37
+ return;
38
+ try {
39
+ unlinkSync(file);
40
+ }
41
+ catch {
42
+ // best effort
43
+ }
44
+ }
45
+ /** True when the stored question can no longer be answered. */
46
+ export function challengeExpired(c, now = Date.now()) {
47
+ const t = Date.parse(c.expiresAt);
48
+ return Number.isFinite(t) && t <= now;
49
+ }
50
+ function parseBody(body) {
51
+ try {
52
+ const json = JSON.parse(body);
53
+ return json && typeof json === "object" ? json : null;
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ }
59
+ /**
60
+ * Parse a 428 `challenge_required` body into a pending challenge, or null when
61
+ * the body is not one (then the caller reports a generic failure).
62
+ */
63
+ export function parseChallenge(body, signupUrl, now = Date.now()) {
64
+ const json = parseBody(body);
65
+ if (!json || json.error !== "challenge_required")
66
+ return null;
67
+ const ch = json.challenge;
68
+ if (!ch || typeof ch.id !== "string" || !ch.id || typeof ch.prompt !== "string" || !ch.prompt.trim())
69
+ return null;
70
+ const ttl = typeof ch.expires_in === "number" && ch.expires_in > 0 ? ch.expires_in : 600;
71
+ return { id: ch.id, prompt: ch.prompt.trim(), expiresAt: new Date(now + ttl * 1000).toISOString(), signupUrl };
72
+ }
73
+ /** True when a 428 says the previous answer was wrong or came too late. */
74
+ export function wasWrongAnswer(body) {
75
+ const msg = parseBody(body)?.message;
76
+ return typeof msg === "string" && /^wrong or expired/i.test(msg.trim());
77
+ }
78
+ /** True when a 429 is the per-network lock after too many questions or wrong answers. */
79
+ export function isChallengeLocked(body) {
80
+ return parseBody(body)?.error === "challenge_locked";
81
+ }
82
+ /** The error an agent sees while a question is waiting for its answer. */
83
+ export function challengeError(c, opts = {}) {
84
+ const lead = opts.wrong ? "That answer was wrong or came too late, so here is a new question. " : "";
85
+ return new ToolkitError({
86
+ code: "SIGNUP_CHALLENGE",
87
+ message: `${lead}Zenrows needs one quick answer before it creates an account: ${c.prompt}`,
88
+ likely_cause: "No API key is configured, and new accounts created by agents answer one short question first.",
89
+ next_action: `Answer the question yourself, then run: ${ANSWER_COMMAND}. When it succeeds, re-run your original command.`,
90
+ suggested_commands: [ANSWER_COMMAND, "zenrows login --api-key <your-key>"],
91
+ challenge: { id: c.id, question: c.prompt, expires_at: c.expiresAt },
92
+ });
93
+ }
94
+ export function challengeLockedError(retryAfter) {
95
+ return new ToolkitError({
96
+ code: "SIGNUP_CHALLENGE_LOCKED",
97
+ message: "Too many signup questions or wrong answers from this network, so Zenrows paused account creation here.",
98
+ likely_cause: "Too many signup questions asked, or wrong answers given, from this network within an hour.",
99
+ next_action: "Try again in an hour by re-running your original command, which asks a new question. If you already have a Zenrows API key, use it now: zenrows login --api-key <key> (or set ZENROWS_API_KEY).",
100
+ suggested_commands: ["zenrows login --api-key <your-key>"],
101
+ ...(retryAfter !== undefined ? { retry_after: retryAfter } : {}),
102
+ });
103
+ }
104
+ export function noChallengeError(expired) {
105
+ return new ToolkitError({
106
+ code: "SIGNUP_CHALLENGE_MISSING",
107
+ message: expired ? "The signup question expired before it was answered." : "There is no signup question waiting for an answer.",
108
+ likely_cause: expired
109
+ ? "Questions are valid for a few minutes."
110
+ : "No signup was attempted from this directory, or the question was already answered.",
111
+ next_action: "Re-run your original command (for example zenrows fetch <url>) to get a new question, then answer it with: " + ANSWER_COMMAND,
112
+ suggested_commands: ["zenrows fetch <url>"],
113
+ });
114
+ }
@@ -70,6 +70,7 @@ export function ensureGitignore(paths) {
70
70
  `${WORKSPACE_DIRNAME}/secrets.json`,
71
71
  `${WORKSPACE_DIRNAME}/config.json`,
72
72
  `${WORKSPACE_DIRNAME}/account.json`,
73
+ `${WORKSPACE_DIRNAME}/signup-challenge.json`,
73
74
  `${WORKSPACE_DIRNAME}/runs/`,
74
75
  `${WORKSPACE_DIRNAME}/traces/`,
75
76
  `${WORKSPACE_DIRNAME}/logs/`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenrows/cli",
3
- "version": "1.9.0",
3
+ "version": "1.11.0",
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",
@@ -17,7 +17,7 @@
17
17
  "command": "zenrows extract",
18
18
  "backend": "Extract — GET https://api.zenrows.com/v1/ (extract=auto, css_extractor, outputs, response_type; autoparse deprecated)",
19
19
  "requiresAuth": true,
20
- "notes": "Default is extract=auto, which works on any prepared domain with any key. A REQS007 (legacy AUTH010) is reported, never retried, with the next step for its reason: on a domain not prepared yet (EXTRACT_DOMAIN_NOT_PREPARED), prepare it with `zenrows extract prepare <domain> --url <page> [--wait]` (POST /v1/extract/prepared-domains: 1 to 5 example URLs, a few minutes, no credits) and check it with `zenrows extract prepare status <domain>`; on a page Extract did not recognise on a prepared domain (EXTRACT_PAGE_NOT_RECOGNISED), try a product, article or listing page, retry it once in a few minutes, or use --css, because preparing again does not change it. CSS / outputs / markdown work on any domain; autoparse is deprecated and runs only with --autoparse."
20
+ "notes": "Default is extract=auto, which works on any prepared domain with any key. A REQS007 (legacy AUTH010) is reported, never retried, with the next step for its reason: on a domain not prepared yet (EXTRACT_DOMAIN_NOT_PREPARED), prepare it with `zenrows extract prepare <domain> --url <page> [--wait]` (POST /v1/extract/prepared-domains: 1 to 5 example URLs; one works, we recommend 3 or more of the same page type because Extract compares pages of one type to check the fields it found and fill in fields one page is missing; a few minutes, no credits) and check it with `zenrows extract prepare status <domain>`; on a page Extract did not recognise on a prepared domain (EXTRACT_PAGE_NOT_RECOGNISED), try a product, article or listing page, retry it once in a few minutes, or use --css, because preparing again does not change it. CSS / outputs / markdown work on any domain; autoparse is deprecated and runs only with --autoparse."
21
21
  },
22
22
  "batch": {
23
23
  "key": "batch",
@@ -43,15 +43,21 @@ of three cases it is and prints the next step. It never retries with Autoparse.
43
43
  Preparing a domain:
44
44
 
45
45
  ```
46
- zenrows extract https://example.com/p/1 # 1. fails: EXTRACT_DOMAIN_NOT_PREPARED
46
+ zenrows extract https://www.example.com/products/a # 1. fails: EXTRACT_DOMAIN_NOT_PREPARED
47
47
  zenrows extract prepare example.com \
48
- --url https://example.com/p/1 --url https://example.com/list --wait
48
+ --url https://www.example.com/products/a \
49
+ --url https://www.example.com/products/b \
50
+ --url https://www.example.com/products/c --wait
49
51
  # 2. prepare it and wait until ready (a few minutes)
50
- zenrows extract https://example.com/p/1 # 3. extract again
52
+ zenrows extract https://www.example.com/products/a # 3. extract again
51
53
  ```
52
54
 
53
- - `--url`: 1 to 5 real pages you want to extract, one per page kind (for
54
- example a product page and a listing page).
55
+ - `--url`: 1 to 5 real pages you want to extract. One works; we recommend 3 or
56
+ more of the same page type (e.g. 3 product pages), because Extract compares
57
+ pages of one type to check the fields it found and fill in fields one page is
58
+ missing. Several page types can share the 5 (e.g. 3 product pages + 2
59
+ search-result pages). With fewer than 3 the command prints a tip on stderr;
60
+ it is never an error.
55
61
  - Without `--wait` the command returns at once; then check about every 30
56
62
  seconds with `zenrows extract prepare status example.com` until it says
57
63
  `ready`. `--wait` polls at the same pace.
@@ -20,6 +20,13 @@ the toolkit automatically creates a **free, unclaimed Zenrows Free plan account*
20
20
  key locally — no human step required. To opt out, pass `--no-signup` or set
21
21
  `auto_signup: false` in `.zenrows/policy.json`, then use `zenrows login --api-key <key>`.
22
22
 
23
+ Before it creates the account, Zenrows may ask you one short question (error code
24
+ `SIGNUP_CHALLENGE`, with the question in the message and in `error.challenge.question`).
25
+ Answer it yourself, do not ask the human, then run `zenrows signup --answer "<your answer>"`
26
+ and re-run your original command. A wrong answer returns a new question the same way.
27
+ After too many questions or wrong answers from one network (`SIGNUP_CHALLENGE_LOCKED`), wait an hour or use
28
+ `zenrows login --api-key <key>`.
29
+
23
30
  When the Free plan nears its limit (or the user asks), tell the human they can **claim** the
24
31
  account to keep their usage and history: run `zenrows account claim` (or open the claim URL
25
32
  shown on quota errors). It prints a valid link, requesting a fresh one when the stored link is
@@ -83,7 +90,8 @@ Run `zenrows status` for the live capability matrix. As of this toolkit:
83
90
 
84
91
  - Extract: `extract=auto` works on any prepared domain. On one not prepared yet
85
92
  (`EXTRACT_DOMAIN_NOT_PREPARED`), run `zenrows extract prepare <domain> --url
86
- <page> --wait`, then extract again. `EXTRACT_PAGE_NOT_RECOGNISED` means the
93
+ <page> --wait` (repeat `--url`, 1 to 5: one works; we recommend 3 or more of
94
+ the same page type), then extract again. `EXTRACT_PAGE_NOT_RECOGNISED` means the
87
95
  domain is prepared but not this page: do not prepare again; see [[extract]].
88
96
  - Batch: the reliable path at scale, at Fetch's prices (create, list, status, wait, results, content, pause, resume, rerun, delete). Estimate specs locally with no key.
89
97
  - Crawl: collects the URLs behind one start page, optionally with each page's HTML.