@zenrows/cli 1.6.2 → 1.8.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 +67 -11
- package/agent-plugin/skills/batch-jobs/SKILL.md +16 -3
- package/agent-plugin/skills/zenrows/SKILL.md +2 -1
- package/dist/src/adapters/batch.js +237 -59
- package/dist/src/cli/commands/account.js +37 -27
- package/dist/src/cli/commands/batch.js +667 -125
- package/dist/src/cli/commands/browser.js +4 -1
- package/dist/src/cli/commands/crawl.js +6 -3
- package/dist/src/cli/commands/extract.js +4 -1
- package/dist/src/cli/commands/fetch.js +4 -1
- package/dist/src/cli/commands/signup.js +3 -2
- package/dist/src/cli/index.js +2 -1
- package/dist/src/core/agent-account.js +111 -14
- package/dist/src/core/auth.js +4 -10
- package/dist/src/core/batch-api.js +400 -146
- package/dist/src/core/browser-api.js +2 -1
- package/dist/src/core/claim-link.js +237 -0
- package/dist/src/core/config.js +1 -1
- package/dist/src/core/crawl-api.js +2 -1
- package/dist/src/core/ensure-key.js +1 -0
- package/dist/src/core/errors.js +27 -11
- package/dist/src/core/http.js +4 -3
- package/dist/src/core/nudge.js +3 -1
- package/dist/src/core/policy.js +13 -6
- package/dist/src/core/provenance.js +10 -2
- package/dist/src/core/usage.js +2 -1
- package/dist/src/core/workspace.js +29 -1
- package/package.json +1 -1
- package/recipes/batch-estimate/RECIPE.md +6 -3
- package/skills/batch-jobs/SKILL.md +160 -43
- package/skills/crawl/SKILL.md +1 -1
- package/skills/trace-debug/SKILL.md +17 -2
- package/skills/zenrows/SKILL.md +6 -3
- package/templates/batch-jsonl-pipeline/README.md +10 -5
|
@@ -23,6 +23,7 @@ import { newRunId, writeRun } from "../../core/artifacts.js";
|
|
|
23
23
|
import { ToolkitError } from "../../core/errors.js";
|
|
24
24
|
import { parse, asString, asNumber } from "../command.js";
|
|
25
25
|
import { printError, writeOut } from "../output.js";
|
|
26
|
+
import { refreshClaimAdvice } from "../../core/claim-link.js";
|
|
26
27
|
import { createSession, sessionCall, closeSession, decodeBinary, connectUrl, normalizeSelectValue, } from "../../core/browser-api.js";
|
|
27
28
|
const VERBS = {
|
|
28
29
|
navigate: { verb: "navigate", opts: { url: { type: "string" } }, required: ["url"], body: (v) => ({ url: asString(v.url) }) },
|
|
@@ -155,7 +156,9 @@ export const browser = {
|
|
|
155
156
|
});
|
|
156
157
|
}
|
|
157
158
|
}
|
|
158
|
-
catch (
|
|
159
|
+
catch (caught) {
|
|
160
|
+
// A quota error's claim advice is built from local state; check it is still current.
|
|
161
|
+
const err = await refreshClaimAdvice(caught);
|
|
159
162
|
printError(err, ctx.json);
|
|
160
163
|
return err instanceof ToolkitError && err.code === "POLICY_BROWSER_DISABLED" ? 2 : 1;
|
|
161
164
|
}
|
|
@@ -18,6 +18,7 @@ import { asNumber, asString, parse } from "../command.js";
|
|
|
18
18
|
import { ToolkitError, isKeyCapReached, keyCapReached, quotaExhausted } from "../../core/errors.js";
|
|
19
19
|
import { readAccount } from "../../core/agent-account.js";
|
|
20
20
|
import { printError, writeOut, writeOutStream } from "../output.js";
|
|
21
|
+
import { refreshClaimAdvice, storedClaimHint } from "../../core/claim-link.js";
|
|
21
22
|
/** The API's default for `max_pages` (and `max_items`) when the flag is unset. */
|
|
22
23
|
const DEFAULT_MAX_PAGES = 100;
|
|
23
24
|
/** Largest `depth` / `max_items` / `max_pages` the API takes. */
|
|
@@ -33,7 +34,7 @@ export const crawl = {
|
|
|
33
34
|
" leave it out for no depth limit (the caps below stop the crawl)",
|
|
34
35
|
" --max-items <n> stop after keeping n URLs (API default 100)",
|
|
35
36
|
" --max-pages <n> stop after fetching n pages (API default 100; bounds cost);",
|
|
36
|
-
" the local policy max_pages_per_run caps it (default
|
|
37
|
+
" the local policy max_pages_per_run caps it (default 10000)",
|
|
37
38
|
" --include-patterns <p> keep only URLs containing this substring (repeatable)",
|
|
38
39
|
" --exclude-patterns <p> drop URLs containing this substring (repeatable)",
|
|
39
40
|
" --output-format <html|markdown> also fetch each kept URL's page, as HTML or Markdown",
|
|
@@ -95,7 +96,9 @@ export const crawl = {
|
|
|
95
96
|
});
|
|
96
97
|
}
|
|
97
98
|
}
|
|
98
|
-
catch (
|
|
99
|
+
catch (caught) {
|
|
100
|
+
// A quota error's claim advice is built from local state; check it is still current.
|
|
101
|
+
const err = await refreshClaimAdvice(caught);
|
|
99
102
|
printError(err, ctx.json);
|
|
100
103
|
return 1;
|
|
101
104
|
}
|
|
@@ -462,7 +465,7 @@ export function crawlFailure(c) {
|
|
|
462
465
|
}
|
|
463
466
|
if (code === "insufficient_credits") {
|
|
464
467
|
const acct = readAccount();
|
|
465
|
-
const quota = quotaExhausted(where, acct
|
|
468
|
+
const quota = quotaExhausted(where, storedClaimHint(acct), { status: null, detail }).toJSON();
|
|
466
469
|
return new ToolkitError({ ...quota, ...ids, code: "CRAWL_INSUFFICIENT_CREDITS", suggested_commands: [...(quota.suggested_commands ?? []), getCmd] });
|
|
467
470
|
}
|
|
468
471
|
const why = [code, c.error?.detail].filter(Boolean).join(": ");
|
|
@@ -17,6 +17,7 @@ import { runExtract } from "../../adapters/extract.js";
|
|
|
17
17
|
import { formatRequestCost } from "../../core/http.js";
|
|
18
18
|
import { parse, asString, asNumber } from "../command.js";
|
|
19
19
|
import { printError, writeOut } from "../output.js";
|
|
20
|
+
import { refreshClaimAdvice } from "../../core/claim-link.js";
|
|
20
21
|
export const extract = {
|
|
21
22
|
name: "extract",
|
|
22
23
|
summary: "Turn a protected page into structured data (Extract / Autoparse / CSS / Markdown).",
|
|
@@ -175,7 +176,9 @@ export const extract = {
|
|
|
175
176
|
await maybeNudgeClaim(config, {});
|
|
176
177
|
return 0;
|
|
177
178
|
}
|
|
178
|
-
catch (
|
|
179
|
+
catch (caught) {
|
|
180
|
+
// A quota error's claim advice is built from local state; check it is still current.
|
|
181
|
+
const err = await refreshClaimAdvice(caught);
|
|
179
182
|
writeRun({
|
|
180
183
|
runId,
|
|
181
184
|
command: "zenrows extract",
|
|
@@ -16,6 +16,7 @@ import { runFetch } from "../../adapters/protected-fetch.js";
|
|
|
16
16
|
import { formatRequestCost } from "../../core/http.js";
|
|
17
17
|
import { parse, asString, asNumber } from "../command.js";
|
|
18
18
|
import { printError, writeOut } from "../output.js";
|
|
19
|
+
import { refreshClaimAdvice } from "../../core/claim-link.js";
|
|
19
20
|
export const fetch_ = {
|
|
20
21
|
name: "fetch",
|
|
21
22
|
summary: "Retrieve a protected page (Fetch).",
|
|
@@ -152,7 +153,9 @@ export const fetch_ = {
|
|
|
152
153
|
await maybeNudgeClaim(config, {});
|
|
153
154
|
return 0;
|
|
154
155
|
}
|
|
155
|
-
catch (
|
|
156
|
+
catch (caught) {
|
|
157
|
+
// A quota error's claim advice is built from local state; check it is still current.
|
|
158
|
+
const err = await refreshClaimAdvice(caught);
|
|
156
159
|
writeRun({
|
|
157
160
|
runId,
|
|
158
161
|
command: "zenrows fetch",
|
|
@@ -21,12 +21,13 @@ export const signup = {
|
|
|
21
21
|
if (values.agent) {
|
|
22
22
|
const account = readAccount();
|
|
23
23
|
if (account) {
|
|
24
|
-
log.info("An agent account already exists. Claim it:
|
|
24
|
+
log.info("An agent account already exists. Claim it with: zenrows account claim");
|
|
25
25
|
return 0;
|
|
26
26
|
}
|
|
27
27
|
const res = await signupAgent();
|
|
28
28
|
saveApiKey(res.apiKey);
|
|
29
|
-
|
|
29
|
+
const now = new Date().toISOString();
|
|
30
|
+
writeAccount({ accountId: res.accountId, unclaimed: true, claimUrl: res.claimUrl, createdAt: now, claimUrlIssuedAt: now });
|
|
30
31
|
log.success("Created a Zenrows Free plan account.");
|
|
31
32
|
log.dim(`Claim it anytime: ${res.claimUrl}`);
|
|
32
33
|
if (ctx.json)
|
package/dist/src/cli/index.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { CLI_VERSION } from "../core/config.js";
|
|
10
10
|
import { ToolkitError } from "../core/errors.js";
|
|
11
|
+
import { refreshClaimAdvice } from "../core/claim-link.js";
|
|
11
12
|
import { log, ANSI, c } from "../core/logger.js";
|
|
12
13
|
import { makeAssetCommand } from "./asset-command.js";
|
|
13
14
|
import { printError } from "./output.js";
|
|
@@ -82,7 +83,7 @@ export async function main(rawArgv) {
|
|
|
82
83
|
return await cmd.run(argv, ctx);
|
|
83
84
|
}
|
|
84
85
|
catch (err) {
|
|
85
|
-
printError(err, json);
|
|
86
|
+
printError(await refreshClaimAdvice(err), json);
|
|
86
87
|
// Any error exits 1 — the exit code doesn't encode error kind. Machine
|
|
87
88
|
// consumers read the precise `error.code` from the --json output instead.
|
|
88
89
|
return 1;
|
|
@@ -5,13 +5,13 @@
|
|
|
5
5
|
* It never holds the API key (that lives in secrets.json, 0600) — only the
|
|
6
6
|
* accountId, the human-facing claim URL, and claim metadata.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import { existsSync, unlinkSync } from "node:fs";
|
|
9
9
|
import { attributionEnabled, getOrCreateTelemetryId, loadConfig, CLI_USER_AGENT, CLI_VERSION } from "./config.js";
|
|
10
10
|
import { agentClientHeader } from "./agent-client.js";
|
|
11
11
|
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
|
-
import { findWorkspace, readJson, workspacePaths,
|
|
14
|
+
import { findWorkspace, readJson, workspacePaths, writePrivateJson } from "./workspace.js";
|
|
15
15
|
/** Env var to override the signup endpoint (highest priority; for local testing). */
|
|
16
16
|
export const SIGNUP_URL_ENV = "ZENROWS_AGENT_SIGNUP_URL";
|
|
17
17
|
/** Env var to override the discovery base origin (for local testing). */
|
|
@@ -49,7 +49,9 @@ export async function discoverSignupUrl(projectRoot, opts = {}) {
|
|
|
49
49
|
return null;
|
|
50
50
|
const json = (await res.json());
|
|
51
51
|
const endpoint = json?.agent_auth?.signup_endpoint;
|
|
52
|
-
|
|
52
|
+
// The key is later sent to URLs derived from this one, so only a Zenrows
|
|
53
|
+
// https endpoint is accepted from a document fetched over the network.
|
|
54
|
+
if (typeof endpoint === "string" && isZenrowsHttpsUrl(endpoint.trim()))
|
|
53
55
|
return endpoint.trim();
|
|
54
56
|
return null;
|
|
55
57
|
}
|
|
@@ -89,7 +91,7 @@ export async function signupCandidates(projectRoot, opts = {}) {
|
|
|
89
91
|
export async function resolveSignupUrl(projectRoot, opts = {}) {
|
|
90
92
|
return (await signupCandidates(projectRoot, opts))[0];
|
|
91
93
|
}
|
|
92
|
-
function accountPath(projectRoot) {
|
|
94
|
+
export function accountPath(projectRoot) {
|
|
93
95
|
const ws = projectRoot ? workspacePaths(projectRoot) : (findWorkspace() ?? workspacePaths());
|
|
94
96
|
return ws.account;
|
|
95
97
|
}
|
|
@@ -98,14 +100,8 @@ export function readAccount(projectRoot) {
|
|
|
98
100
|
}
|
|
99
101
|
export function writeAccount(acct, projectRoot) {
|
|
100
102
|
const file = accountPath(projectRoot);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
// account.json holds the single-use claim token — restrict to owner-only.
|
|
104
|
-
chmodSync(file, 0o600);
|
|
105
|
-
}
|
|
106
|
-
catch {
|
|
107
|
-
// best-effort on platforms without POSIX permissions
|
|
108
|
-
}
|
|
103
|
+
// account.json holds the single-use claim token: owner-only from creation.
|
|
104
|
+
writePrivateJson(file, acct);
|
|
109
105
|
}
|
|
110
106
|
/** Remove local agent account metadata (used by `zenrows logout`). */
|
|
111
107
|
export function clearAccount(projectRoot) {
|
|
@@ -201,7 +197,108 @@ function signupError(status, body) {
|
|
|
201
197
|
* `signupUrl` override applies here too (local/staging testing).
|
|
202
198
|
*/
|
|
203
199
|
export async function resolveAccountUrl(projectRoot, opts = {}) {
|
|
204
|
-
return (await resolveSignupUrl(projectRoot, opts)
|
|
200
|
+
return siblingOfSignup(await resolveSignupUrl(projectRoot, opts), "account");
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* `…/agent/signup` → `…/agent/<name>`. A signup URL that does not end in
|
|
204
|
+
* `/signup` gives no safe sibling, so the built-in endpoint's sibling is used
|
|
205
|
+
* rather than sending the key somewhere unexpected.
|
|
206
|
+
*/
|
|
207
|
+
export function siblingOfSignup(signupUrl, name) {
|
|
208
|
+
const re = /\/signup\/?$/;
|
|
209
|
+
// These two endpoints receive the API key. A signup URL from the environment
|
|
210
|
+
// or a project's config (which a cloned repository could ship) is only
|
|
211
|
+
// trusted when it is Zenrows or this machine; anything else gets the
|
|
212
|
+
// built-in endpoint.
|
|
213
|
+
const base = re.test(signupUrl) && isAgentKeyDestination(signupUrl) ? signupUrl : AGENT_SIGNUP_API_URL;
|
|
214
|
+
return base.replace(re, `/${name}`);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Where the CLI may send the API key for the agent account endpoints: a
|
|
218
|
+
* Zenrows https URL, or http(s) on loopback (local testing). No userinfo.
|
|
219
|
+
*/
|
|
220
|
+
export function isAgentKeyDestination(value) {
|
|
221
|
+
if (isZenrowsHttpsUrl(value))
|
|
222
|
+
return true;
|
|
223
|
+
try {
|
|
224
|
+
const u = new URL(value);
|
|
225
|
+
const host = u.hostname.toLowerCase();
|
|
226
|
+
return ((u.protocol === "https:" || u.protocol === "http:") &&
|
|
227
|
+
u.username === "" &&
|
|
228
|
+
u.password === "" &&
|
|
229
|
+
(host === "127.0.0.1" || host === "localhost" || host === "[::1]"));
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/** True for an https URL on zenrows.com or a subdomain, with no userinfo. */
|
|
236
|
+
export function isZenrowsHttpsUrl(value) {
|
|
237
|
+
if (typeof value !== "string")
|
|
238
|
+
return false;
|
|
239
|
+
try {
|
|
240
|
+
const u = new URL(value);
|
|
241
|
+
const host = u.hostname.toLowerCase();
|
|
242
|
+
return (u.protocol === "https:" &&
|
|
243
|
+
u.username === "" &&
|
|
244
|
+
u.password === "" &&
|
|
245
|
+
(host === "zenrows.com" || host.endsWith(".zenrows.com")));
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/** The fresh-claim-link endpoint, derived from the signup URL like the status one. */
|
|
252
|
+
export async function resolveClaimUrl(projectRoot, opts = {}) {
|
|
253
|
+
return siblingOfSignup(await resolveSignupUrl(projectRoot, opts), "claim");
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Mint a fresh claim link for the account behind `apiKey`. Each call
|
|
257
|
+
* invalidates the previous link and the endpoint is rate-limited per key, so
|
|
258
|
+
* callers only ask when the link they hold is missing or near expiry.
|
|
259
|
+
* A 409 `already_claimed` means a person owns the account. Throws ToolkitError
|
|
260
|
+
* on any other failure.
|
|
261
|
+
*/
|
|
262
|
+
export async function requestClaimLink(apiKey, opts = {}) {
|
|
263
|
+
const url = opts.url ?? (await resolveClaimUrl(opts.projectRoot, { fetchImpl: opts.fetchImpl }));
|
|
264
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
265
|
+
const res = await doFetch(url, {
|
|
266
|
+
method: "POST",
|
|
267
|
+
headers: { "X-API-Key": apiKey, Accept: "application/json", "User-Agent": CLI_USER_AGENT, ...agentClientHeader() },
|
|
268
|
+
});
|
|
269
|
+
const body = await res.text();
|
|
270
|
+
if (res.status === 200) {
|
|
271
|
+
let json = null;
|
|
272
|
+
try {
|
|
273
|
+
json = JSON.parse(body);
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
// not JSON: reported below
|
|
277
|
+
}
|
|
278
|
+
if (!json || typeof json.claimUrl !== "string") {
|
|
279
|
+
throw new ToolkitError({
|
|
280
|
+
code: "FETCH_FAILED",
|
|
281
|
+
message: "The claim-link endpoint answered HTTP 200 without a claim link (unexpected response body).",
|
|
282
|
+
likely_cause: body.slice(0, 240),
|
|
283
|
+
next_action: "Retry later.",
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
status: "unclaimed",
|
|
288
|
+
claimUrl: json.claimUrl,
|
|
289
|
+
...(typeof json.expiresAt === "string" ? { expiresAt: json.expiresAt } : {}),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
if (res.status === 409 && /already_claimed/.test(body))
|
|
293
|
+
return { status: "claimed" };
|
|
294
|
+
if (res.status === 409 && /not_agent_account/.test(body))
|
|
295
|
+
return { status: "not_agent" };
|
|
296
|
+
throw new ToolkitError({
|
|
297
|
+
code: res.status === 401 || res.status === 403 ? "AUTH_INVALID" : "FETCH_FAILED",
|
|
298
|
+
message: `Claim link request failed (HTTP ${res.status}).`,
|
|
299
|
+
likely_cause: body.slice(0, 240),
|
|
300
|
+
next_action: res.status === 429 ? "Too many claim links requested for this key; retry in an hour." : "Retry later.",
|
|
301
|
+
});
|
|
205
302
|
}
|
|
206
303
|
/**
|
|
207
304
|
* Ask the backend whether the account behind `apiKey` has been claimed
|
|
@@ -209,7 +306,7 @@ export async function resolveAccountUrl(projectRoot, opts = {}) {
|
|
|
209
306
|
* `X-API-Key` header. Throws ToolkitError on failure.
|
|
210
307
|
*/
|
|
211
308
|
export async function fetchAccountStatus(apiKey, opts = {}) {
|
|
212
|
-
const url = opts.url ?? (await resolveAccountUrl());
|
|
309
|
+
const url = opts.url ?? (await resolveAccountUrl(opts.projectRoot, { fetchImpl: opts.fetchImpl }));
|
|
213
310
|
const doFetch = opts.fetchImpl ?? fetch;
|
|
214
311
|
const res = await doFetch(url, {
|
|
215
312
|
method: "GET",
|
package/dist/src/core/auth.js
CHANGED
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
* Resolution order: explicit secrets file → ZENROWS_API_KEY env var.
|
|
5
5
|
* The key is never printed in full and never written into run artifacts.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
8
|
import { ToolkitError } from "./errors.js";
|
|
9
9
|
import { registerSecret } from "./logger.js";
|
|
10
10
|
import { mask } from "./redact.js";
|
|
11
|
-
import { findWorkspace, readJson, workspacePaths,
|
|
11
|
+
import { findWorkspace, readJson, workspacePaths, writePrivateJson } from "./workspace.js";
|
|
12
12
|
export const ENV_KEY = "ZENROWS_API_KEY";
|
|
13
13
|
function secretsPath(projectRoot) {
|
|
14
14
|
const ws = projectRoot ? workspacePaths(projectRoot) : (findWorkspace() ?? workspacePaths());
|
|
@@ -49,20 +49,14 @@ export function saveApiKey(apiKey, projectRoot) {
|
|
|
49
49
|
});
|
|
50
50
|
}
|
|
51
51
|
const file = secretsPath(projectRoot);
|
|
52
|
-
|
|
53
|
-
try {
|
|
54
|
-
chmodSync(file, 0o600);
|
|
55
|
-
}
|
|
56
|
-
catch {
|
|
57
|
-
// best-effort on platforms without POSIX permissions
|
|
58
|
-
}
|
|
52
|
+
writePrivateJson(file, { apiKey: trimmed });
|
|
59
53
|
registerSecret(trimmed);
|
|
60
54
|
}
|
|
61
55
|
export function clearApiKey(projectRoot) {
|
|
62
56
|
const file = secretsPath(projectRoot);
|
|
63
57
|
if (!existsSync(file))
|
|
64
58
|
return false;
|
|
65
|
-
|
|
59
|
+
writePrivateJson(file, {});
|
|
66
60
|
return true;
|
|
67
61
|
}
|
|
68
62
|
/** Throw AUTH_MISSING if no key is configured. */
|