@zenrows/cli 1.8.0 → 1.10.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 +36 -7
- package/agent-plugin/README.md +1 -1
- package/agent-plugin/skills/cost-control/SKILL.md +2 -2
- package/agent-plugin/skills/extract/SKILL.md +49 -9
- package/agent-plugin/skills/interact-browser/SKILL.md +4 -2
- package/agent-plugin/skills/protected-fetch/SKILL.md +1 -1
- package/agent-plugin/skills/zenrows/SKILL.md +7 -3
- package/dist/src/adapters/extract.js +18 -19
- package/dist/src/cli/commands/extract-prepare.js +168 -0
- package/dist/src/cli/commands/extract.js +21 -13
- package/dist/src/cli/commands/signup.js +42 -7
- package/dist/src/core/agent-account.js +46 -6
- package/dist/src/core/config.js +1 -1
- package/dist/src/core/ensure-key.js +11 -5
- package/dist/src/core/errors.js +3 -0
- package/dist/src/core/http.js +78 -20
- package/dist/src/core/prepared-domains-api.js +289 -0
- package/dist/src/core/signup-challenge.js +114 -0
- package/dist/src/core/workspace.js +1 -0
- package/evals/extract-smoke/README.md +1 -1
- package/package.json +1 -1
- package/registry/capabilities.json +3 -3
- package/registry/skills.json +2 -2
- package/registry/templates.json +1 -1
- package/skills/extract/SKILL.md +50 -6
- package/skills/zenrows/SKILL.md +13 -3
- package/templates/extract-pricing-pages/README.md +3 -5
- package/workflows/competitor-intelligence/WORKFLOW.md +2 -2
|
@@ -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 {
|
|
9
|
-
import {
|
|
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:
|
|
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, {
|
|
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
|
-
|
|
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(
|
|
121
|
-
|
|
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
|
-
|
|
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({
|
package/dist/src/core/config.js
CHANGED
|
@@ -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.
|
|
17
|
+
export const CLI_VERSION = "1.10.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
|
-
|
|
11
|
-
|
|
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,
|
|
20
|
-
|
|
21
|
-
return res.apiKey;
|
|
26
|
+
writeAccount(account, projectRoot);
|
|
27
|
+
return account;
|
|
22
28
|
}
|
package/dist/src/core/errors.js
CHANGED
|
@@ -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
|
}
|
package/dist/src/core/http.js
CHANGED
|
@@ -12,6 +12,7 @@ import { ENV_KEY, resolveApiKey } from "./auth.js";
|
|
|
12
12
|
import { registerSecret } from "./logger.js";
|
|
13
13
|
import { CLI_USER_AGENT } from "./config.js";
|
|
14
14
|
import { agentClientHeader } from "./agent-client.js";
|
|
15
|
+
import { normalizeDomain, PAGE_NOT_RECOGNISED_STEPS, prepareCommand } from "./prepared-domains-api.js";
|
|
15
16
|
/**
|
|
16
17
|
* Client-side timeout for a Fetch and Extract request, deliberately ABOVE the
|
|
17
18
|
* gateway's own 90s request budget.
|
|
@@ -99,17 +100,10 @@ export async function scrape(apiBase, apiKey, params, opts = {}) {
|
|
|
99
100
|
concurrencyRemaining: numOrNull(res.headers.get("concurrency-remaining")),
|
|
100
101
|
redactedUrl: redacted,
|
|
101
102
|
};
|
|
102
|
-
// REQS007 on extract=auto:
|
|
103
|
-
//
|
|
104
|
-
// maps to the same code. Checked before the 403 auth branch below.
|
|
103
|
+
// REQS007 on extract=auto: Extract has not prepared the domain yet. Reported,
|
|
104
|
+
// never retried: the fix is to prepare the domain, which the error names.
|
|
105
105
|
if (res.status === 403 && params.extract !== undefined && zrErrorCode(body) === "REQS007") {
|
|
106
|
-
throw
|
|
107
|
-
code: "EXTRACT_DOMAIN_NOT_ENABLED",
|
|
108
|
-
message: "Extract has not been prepared for this domain yet.",
|
|
109
|
-
likely_cause: zrErrorDetail(body) ?? "This domain is not prepared for Extract.",
|
|
110
|
-
next_action: "Retry with --autoparse for general-purpose extraction on any site, or get the domain prepared for Extract (POST /v1/extract/prepared-domains).",
|
|
111
|
-
suggested_commands: [`zenrows extract ${params.url} --autoparse`],
|
|
112
|
-
});
|
|
106
|
+
throw extractDomainNotPrepared(params.url, body);
|
|
113
107
|
}
|
|
114
108
|
// 422 no_data_extracted on an Extract request: the page was processed and had
|
|
115
109
|
// nothing to extract. It is an answer, not a failure to reach the target, so
|
|
@@ -163,17 +157,10 @@ export async function scrape(apiBase, apiKey, params, opts = {}) {
|
|
|
163
157
|
}
|
|
164
158
|
}
|
|
165
159
|
if (res.status === 402 && isZenrowsErrorEnvelope(body)) {
|
|
166
|
-
// AUTH010 on extract=auto
|
|
167
|
-
// Extract
|
|
168
|
-
// adapter fall back to autoparse.
|
|
160
|
+
// AUTH010 on extract=auto is the legacy code for REQS007: the domain is
|
|
161
|
+
// not prepared for Extract. Distinct from credit exhaustion (AUTH004).
|
|
169
162
|
if (params.extract !== undefined && zrErrorCode(body) === "AUTH010") {
|
|
170
|
-
throw
|
|
171
|
-
code: "EXTRACT_DOMAIN_NOT_ENABLED",
|
|
172
|
-
message: "Extract is not enabled for this domain yet.",
|
|
173
|
-
likely_cause: zrErrorDetail(body) ?? "This domain is not enabled for Extract.",
|
|
174
|
-
next_action: "Retry with --autoparse for general-purpose extraction on any site, or get the domain prepared for Extract (POST /v1/extract/prepared-domains).",
|
|
175
|
-
suggested_commands: [`zenrows extract ${params.url} --autoparse`],
|
|
176
|
-
});
|
|
163
|
+
throw extractDomainNotPrepared(params.url, body);
|
|
177
164
|
}
|
|
178
165
|
// AUTH014: this key hit one of its credit caps; the account still has credits.
|
|
179
166
|
if (isKeyCapReached(zrErrorCode(body) ?? undefined)) {
|
|
@@ -261,6 +248,77 @@ export async function scrape(apiBase, apiKey, params, opts = {}) {
|
|
|
261
248
|
}
|
|
262
249
|
return result;
|
|
263
250
|
}
|
|
251
|
+
const REFUSAL_REASONS = ["domain_not_prepared", "page_not_recognised", "domain_not_enabled"];
|
|
252
|
+
/**
|
|
253
|
+
* Which REQS007 this is. Only domain_not_prepared is fixed by preparing the
|
|
254
|
+
* domain: on page_not_recognised the domain is already prepared, and
|
|
255
|
+
* resubmitting it returns the existing ready record, so pointing there would
|
|
256
|
+
* loop.
|
|
257
|
+
*
|
|
258
|
+
* The gateway says it in the problem's `reason` field, read first. The sentence
|
|
259
|
+
* match below is only the fallback for a gateway deployed before that field
|
|
260
|
+
* existed, and reads the titles that gateway sends; remove it once every gateway
|
|
261
|
+
* sends `reason`.
|
|
262
|
+
*/
|
|
263
|
+
export function refusalReason(body) {
|
|
264
|
+
let j;
|
|
265
|
+
try {
|
|
266
|
+
j = JSON.parse(body);
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return "domain_not_prepared";
|
|
270
|
+
}
|
|
271
|
+
if (typeof j.reason === "string" && REFUSAL_REASONS.includes(j.reason))
|
|
272
|
+
return j.reason;
|
|
273
|
+
const title = typeof j.title === "string" ? j.title : "";
|
|
274
|
+
const detail = typeof j.detail === "string" ? j.detail : "";
|
|
275
|
+
if (/did not recogni[sz]e this page/i.test(title) || /^Extract serves /.test(detail))
|
|
276
|
+
return "page_not_recognised";
|
|
277
|
+
if (/^Extract is not enabled/i.test(title))
|
|
278
|
+
return "domain_not_enabled";
|
|
279
|
+
return "domain_not_prepared";
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* REQS007 (or the legacy AUTH010) on extract=auto. Each reason names its own
|
|
283
|
+
* next step, because agents copy it: the exact prepare command for a domain not
|
|
284
|
+
* prepared yet, the gateway's advice in the gateway's order for a page it did not
|
|
285
|
+
* recognise. None offers Autoparse.
|
|
286
|
+
*/
|
|
287
|
+
export function extractDomainNotPrepared(url, body) {
|
|
288
|
+
const domain = normalizeDomain(url);
|
|
289
|
+
const detail = zrErrorDetail(body);
|
|
290
|
+
const css = `zenrows extract ${url} --css '{"title":"h1"}'`;
|
|
291
|
+
const reason = refusalReason(body);
|
|
292
|
+
if (reason === "page_not_recognised") {
|
|
293
|
+
return new ToolkitError({
|
|
294
|
+
code: "EXTRACT_PAGE_NOT_RECOGNISED",
|
|
295
|
+
message: `Extract serves ${domain} but did not recognise this page as one it can extract (it may be a page with nothing to extract, such as a home page). ${PAGE_NOT_RECOGNISED_STEPS} The request was not charged.`,
|
|
296
|
+
likely_cause: detail ?? `Extract serves ${domain} but did not recognise this page.`,
|
|
297
|
+
next_action: PAGE_NOT_RECOGNISED_STEPS,
|
|
298
|
+
suggested_commands: [css],
|
|
299
|
+
server_code: "REQS007",
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
if (reason === "domain_not_enabled") {
|
|
303
|
+
return new ToolkitError({
|
|
304
|
+
code: "EXTRACT_DOMAIN_NOT_ENABLED",
|
|
305
|
+
message: `Extract is not enabled for ${domain}, and preparing it does not change this.`,
|
|
306
|
+
likely_cause: detail ?? `Extract is not enabled for ${domain}.`,
|
|
307
|
+
next_action: "Extract this page with --css and explicit selectors, or contact Zenrows support to have the site enabled.",
|
|
308
|
+
suggested_commands: [css],
|
|
309
|
+
server_code: "REQS007",
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
const prepare = prepareCommand(url);
|
|
313
|
+
return new ToolkitError({
|
|
314
|
+
code: "EXTRACT_DOMAIN_NOT_PREPARED",
|
|
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
|
+
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.`,
|
|
318
|
+
suggested_commands: [`${prepare} --wait`, `zenrows extract prepare status ${domain}`, css],
|
|
319
|
+
server_code: "REQS007",
|
|
320
|
+
});
|
|
321
|
+
}
|
|
264
322
|
/**
|
|
265
323
|
* The CLI gave up waiting before the API answered. This is NOT unreachability:
|
|
266
324
|
* the connection was established and the gateway was still working, so telling
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client for Extract's prepared-domains API.
|
|
3
|
+
*
|
|
4
|
+
* `extract=auto` answers REQS007 on a domain Extract has not prepared yet. Preparing
|
|
5
|
+
* it is a call any key can make: `POST {apiBase}/extract/prepared-domains` with the
|
|
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
|
|
8
|
+
* `GET .../allowance` the account's monthly allowance of new domains. Auth is the
|
|
9
|
+
* `X-API-Key` header; errors are problem+json and we branch on `status` + `code`.
|
|
10
|
+
* `fetchImpl` / `sleepImpl` / `now` are injectable for tests.
|
|
11
|
+
*/
|
|
12
|
+
import { ToolkitError } from "./errors.js";
|
|
13
|
+
import { registerSecret } from "./logger.js";
|
|
14
|
+
import { CLI_USER_AGENT, loadConfig } from "./config.js";
|
|
15
|
+
import { agentClientHeader } from "./agent-client.js";
|
|
16
|
+
export const MAX_EXAMPLE_URLS = 5;
|
|
17
|
+
/** How often `--wait` polls, and for how long by default. */
|
|
18
|
+
export const DEFAULT_POLL_INTERVAL_MS = 30_000;
|
|
19
|
+
export const DEFAULT_WAIT_TIMEOUT_MS = 15 * 60_000;
|
|
20
|
+
/**
|
|
21
|
+
* The domain as the API keys it: host only, no scheme, path or leading `www.`.
|
|
22
|
+
* Accepts a URL.
|
|
23
|
+
*
|
|
24
|
+
* Deliberately not the registrable domain: Extract prepares and serves each host
|
|
25
|
+
* on its own. A prepared `es.wikipedia.org` does not cover `pt.wikipedia.org`
|
|
26
|
+
* (REQS007 names `pt.wikipedia.org`), and `wikipedia.org` has no record of its
|
|
27
|
+
* own. The API itself folds `www.` into the bare host, so dropping it here
|
|
28
|
+
* matches what it stores.
|
|
29
|
+
*/
|
|
30
|
+
export function normalizeDomain(input) {
|
|
31
|
+
const trimmed = input.trim();
|
|
32
|
+
let host = trimmed;
|
|
33
|
+
try {
|
|
34
|
+
host = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`).hostname;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// Left as given: the API answers invalid_argument with the reason.
|
|
38
|
+
}
|
|
39
|
+
return host.toLowerCase().replace(/^www\./, "");
|
|
40
|
+
}
|
|
41
|
+
/** The exact command an agent runs to prepare the domain of `url`. */
|
|
42
|
+
export function prepareCommand(url) {
|
|
43
|
+
return `zenrows extract prepare ${normalizeDomain(url)} --url ${url}`;
|
|
44
|
+
}
|
|
45
|
+
function base(opts) {
|
|
46
|
+
return (opts.apiBase ?? loadConfig().apiBase).replace(/\/+$/, "");
|
|
47
|
+
}
|
|
48
|
+
async function request(method, path, domain, opts) {
|
|
49
|
+
registerSecret(opts.apiKey);
|
|
50
|
+
const headers = {
|
|
51
|
+
"X-API-Key": opts.apiKey,
|
|
52
|
+
Accept: "application/json",
|
|
53
|
+
"User-Agent": CLI_USER_AGENT,
|
|
54
|
+
...agentClientHeader(),
|
|
55
|
+
};
|
|
56
|
+
if (opts.body !== undefined)
|
|
57
|
+
headers["Content-Type"] = "application/json";
|
|
58
|
+
let res;
|
|
59
|
+
try {
|
|
60
|
+
res = await (opts.fetchImpl ?? fetch)(`${base(opts)}/extract/prepared-domains${path}`, {
|
|
61
|
+
method,
|
|
62
|
+
headers,
|
|
63
|
+
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
throw new ToolkitError({
|
|
68
|
+
code: "BACKEND_UNAVAILABLE",
|
|
69
|
+
message: "Could not reach the Zenrows API.",
|
|
70
|
+
likely_cause: err instanceof Error ? err.message : String(err),
|
|
71
|
+
next_action: "Check connectivity and retry. Verify the api base in `zenrows config show`.",
|
|
72
|
+
suggested_commands: ["zenrows status", "zenrows config show"],
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
const text = await res.text();
|
|
76
|
+
if (res.status < 200 || res.status >= 300)
|
|
77
|
+
throw prepareError(res.status, text, domain);
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(text);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
throw new ToolkitError({
|
|
83
|
+
code: "EXTRACT_PREPARE_FAILED",
|
|
84
|
+
message: "The prepared-domains API returned a non-JSON answer.",
|
|
85
|
+
likely_cause: text.slice(0, 200),
|
|
86
|
+
next_action: "Retry in a minute.",
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** Map a non-2xx prepared-domains answer to an error that names the next step. */
|
|
91
|
+
export function prepareError(status, body, domain) {
|
|
92
|
+
let p = {};
|
|
93
|
+
try {
|
|
94
|
+
p = JSON.parse(body) ?? {};
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// not problem+json
|
|
98
|
+
}
|
|
99
|
+
const code = typeof p.code === "string" ? p.code.toLowerCase() : undefined;
|
|
100
|
+
const detail = typeof p.detail === "string" ? p.detail : body.slice(0, 200) || `HTTP ${status}`;
|
|
101
|
+
const cause = code ? `(${code}) ${detail}` : detail;
|
|
102
|
+
if (code === "invalid_argument" || status === 400) {
|
|
103
|
+
return new ToolkitError({
|
|
104
|
+
code: "EXTRACT_PREPARE_INVALID",
|
|
105
|
+
message: `The request to prepare ${domain} was rejected.`,
|
|
106
|
+
likely_cause: p.reason ? `${cause} [reason: ${String(p.reason)}]` : cause,
|
|
107
|
+
next_action: `Pass the domain without scheme or path, and 1 to ${MAX_EXAMPLE_URLS} full page URLs from that site with --url.`,
|
|
108
|
+
suggested_commands: [`zenrows extract prepare ${domain} --url https://${domain}/<page>`],
|
|
109
|
+
server_code: code,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
if (code === "not_found" || status === 404) {
|
|
113
|
+
return new ToolkitError({
|
|
114
|
+
code: "EXTRACT_PREPARE_NOT_FOUND",
|
|
115
|
+
message: `This account has not submitted ${domain} for preparation.`,
|
|
116
|
+
likely_cause: cause,
|
|
117
|
+
next_action: `Prepare it first: zenrows extract prepare ${domain} --url <page url> (1 to ${MAX_EXAMPLE_URLS} --url).`,
|
|
118
|
+
suggested_commands: [`zenrows extract prepare ${domain} --url https://${domain}/<page>`],
|
|
119
|
+
server_code: code,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
if (code === "in_progress") {
|
|
123
|
+
return new ToolkitError({
|
|
124
|
+
code: "EXTRACT_PREPARE_IN_PROGRESS",
|
|
125
|
+
message: `${domain} is already being prepared.`,
|
|
126
|
+
likely_cause: cause,
|
|
127
|
+
next_action: "Do not resubmit. Retry the extract in a few minutes.",
|
|
128
|
+
suggested_commands: [`zenrows extract prepare status ${domain}`],
|
|
129
|
+
server_code: code,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
if (code === "idempotency_conflict") {
|
|
133
|
+
return new ToolkitError({
|
|
134
|
+
code: "EXTRACT_PREPARE_INVALID",
|
|
135
|
+
message: "This idempotency key was already used with a different request.",
|
|
136
|
+
likely_cause: cause,
|
|
137
|
+
next_action: "Retry without the key, or with a new one.",
|
|
138
|
+
server_code: code,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
if (code === "out_of_allowance" || status === 429) {
|
|
142
|
+
const used = p.used !== undefined && p.limit !== undefined ? ` (${String(p.used)} of ${String(p.limit)} used)` : "";
|
|
143
|
+
return new ToolkitError({
|
|
144
|
+
code: "EXTRACT_PREPARE_OUT_OF_ALLOWANCE",
|
|
145
|
+
message: `This account has used its monthly allowance of new domains to prepare${used}.`,
|
|
146
|
+
likely_cause: cause,
|
|
147
|
+
next_action: "Do not retry this month. Meanwhile extract this domain with --css and explicit selectors.",
|
|
148
|
+
suggested_commands: [`zenrows extract <url> --css '{"title":"h1"}'`],
|
|
149
|
+
server_code: code,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
if (code === "overloaded" || code === "disabled" || status === 503) {
|
|
153
|
+
return new ToolkitError({
|
|
154
|
+
code: "EXTRACT_PREPARE_UNAVAILABLE",
|
|
155
|
+
message: "Domain preparation is unavailable right now.",
|
|
156
|
+
likely_cause: cause,
|
|
157
|
+
next_action: "Retry in a few minutes; do not loop. Meanwhile extract with --css and explicit selectors.",
|
|
158
|
+
server_code: code,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
if (status === 401 || status === 403) {
|
|
162
|
+
return new ToolkitError({
|
|
163
|
+
code: "AUTH_INVALID",
|
|
164
|
+
message: "Zenrows rejected the API key.",
|
|
165
|
+
likely_cause: cause,
|
|
166
|
+
next_action: "Re-check your key and log in again.",
|
|
167
|
+
suggested_commands: ["zenrows login --api-key <your-key>"],
|
|
168
|
+
server_code: code,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return new ToolkitError({
|
|
172
|
+
code: "EXTRACT_PREPARE_FAILED",
|
|
173
|
+
message: `Preparing ${domain} failed with HTTP ${status}.`,
|
|
174
|
+
likely_cause: cause,
|
|
175
|
+
next_action: "Retry in a few minutes.",
|
|
176
|
+
server_code: code,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
export function submitPreparedDomain(domain, exampleUrls, opts) {
|
|
180
|
+
return request("POST", "", domain, { ...opts, body: { domain, example_urls: exampleUrls } });
|
|
181
|
+
}
|
|
182
|
+
export function getPreparedDomain(domain, opts) {
|
|
183
|
+
return request("GET", `/${encodeURIComponent(domain)}`, domain, opts);
|
|
184
|
+
}
|
|
185
|
+
export function getAllowance(opts) {
|
|
186
|
+
return request("GET", "/allowance", "", opts);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Submit a domain. A 409 in_progress (already being prepared) answers with the
|
|
190
|
+
* current status when this account can read it, so the caller just follows it.
|
|
191
|
+
*/
|
|
192
|
+
export async function prepareDomain(domain, exampleUrls, opts) {
|
|
193
|
+
try {
|
|
194
|
+
return await submitPreparedDomain(domain, exampleUrls, opts);
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
if (!(err instanceof ToolkitError) || err.code !== "EXTRACT_PREPARE_IN_PROGRESS")
|
|
198
|
+
throw err;
|
|
199
|
+
try {
|
|
200
|
+
return { ...(await getPreparedDomain(domain, opts)), already_in_progress: true };
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
throw err;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
export function isTerminal(record) {
|
|
208
|
+
return record.status === "ready" || record.status === "failed";
|
|
209
|
+
}
|
|
210
|
+
/** Poll until ready or failed. Throws EXTRACT_PREPARE_TIMEOUT when the wait runs out. */
|
|
211
|
+
export async function waitForPreparedDomain(domain, opts) {
|
|
212
|
+
const sleep = opts.sleepImpl ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
213
|
+
const now = opts.now ?? Date.now;
|
|
214
|
+
const interval = opts.intervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
215
|
+
const deadline = now() + (opts.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS);
|
|
216
|
+
for (;;) {
|
|
217
|
+
const record = await getPreparedDomain(domain, opts);
|
|
218
|
+
opts.onPoll?.(record);
|
|
219
|
+
if (isTerminal(record))
|
|
220
|
+
return record;
|
|
221
|
+
if (now() + interval > deadline) {
|
|
222
|
+
throw new ToolkitError({
|
|
223
|
+
code: "EXTRACT_PREPARE_TIMEOUT",
|
|
224
|
+
message: `${domain} is still being prepared (state: ${record.state ?? record.status}).`,
|
|
225
|
+
likely_cause: "Preparation usually takes a few minutes; this one is taking longer than the wait.",
|
|
226
|
+
next_action: "Check again later; it keeps running on the server.",
|
|
227
|
+
suggested_commands: [`zenrows extract prepare status ${domain}`],
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
await sleep(interval);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/** Whether a failed preparation used an allowance slot, from the record's failure.slot_consumed. */
|
|
234
|
+
function slotSentence(slotConsumed) {
|
|
235
|
+
if (slotConsumed === true)
|
|
236
|
+
return " This attempt used an allowance slot.";
|
|
237
|
+
if (slotConsumed === false)
|
|
238
|
+
return " This attempt did not use an allowance slot.";
|
|
239
|
+
return "";
|
|
240
|
+
}
|
|
241
|
+
/** What to do after a failed preparation, by failure.code (docs.zenrows.com extract/endpoints). */
|
|
242
|
+
export function failureNextStep(domain, failure) {
|
|
243
|
+
let step;
|
|
244
|
+
switch (failure?.code) {
|
|
245
|
+
case "interrupted":
|
|
246
|
+
case "preparation_exhausted":
|
|
247
|
+
step = `Run the same zenrows extract prepare ${domain} command again.`;
|
|
248
|
+
break;
|
|
249
|
+
case "discovery_failed":
|
|
250
|
+
step = `Run zenrows extract prepare ${domain} again with 1 to ${MAX_EXAMPLE_URLS} --url page URLs.`;
|
|
251
|
+
break;
|
|
252
|
+
case "unpreparable":
|
|
253
|
+
step = `Run zenrows extract prepare ${domain} again with different --url pages from the same site; the same URLs fail again.`;
|
|
254
|
+
break;
|
|
255
|
+
case "preparation_failed":
|
|
256
|
+
step = "Try other --url pages, or contact Zenrows support.";
|
|
257
|
+
break;
|
|
258
|
+
default:
|
|
259
|
+
step = "Contact Zenrows support before preparing this domain again. Meanwhile extract with --css.";
|
|
260
|
+
}
|
|
261
|
+
return step + slotSentence(failure?.slot_consumed);
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* What to do when a page on a prepared domain answers REQS007 with reason
|
|
265
|
+
* page_not_recognised, in the gateway's own order. On a prepared domain Extract
|
|
266
|
+
* learns a new kind of page on its first request, so that answer means this page
|
|
267
|
+
* is not one Extract extracts (a page with nothing to extract, such as a home page) or a
|
|
268
|
+
* transient failure kept it from trying, never that the domain needs preparing
|
|
269
|
+
* again: resubmitting returns the existing ready record, so pointing there would
|
|
270
|
+
* loop.
|
|
271
|
+
*/
|
|
272
|
+
export const PAGE_NOT_RECOGNISED_STEPS = "Try a product, article or listing page on the same site instead. If this page should work, retry it once in a few minutes; do not retry in a loop. Otherwise use --css with explicit selectors. Preparing the domain again does not change this.";
|
|
273
|
+
/** Said once a domain is ready, so a user who then meets EXTRACT_PAGE_NOT_RECOGNISED does not resubmit. */
|
|
274
|
+
const IF_PAGE_NOT_RECOGNISED = `If the extract then fails with EXTRACT_PAGE_NOT_RECOGNISED, Extract did not recognise that page. ${PAGE_NOT_RECOGNISED_STEPS}`;
|
|
275
|
+
/**
|
|
276
|
+
* The next step for a status record. `alreadyReady` is set when a submit answered
|
|
277
|
+
* ready at once: the domain was prepared before this call.
|
|
278
|
+
*/
|
|
279
|
+
export function statusNextStep(record, opts = {}) {
|
|
280
|
+
if (record.status === "ready") {
|
|
281
|
+
const extract = opts.exampleUrl ? ` (\`zenrows extract ${opts.exampleUrl}\`)` : "";
|
|
282
|
+
if (opts.alreadyReady)
|
|
283
|
+
return `${record.domain} is already prepared, so nothing new was submitted: run the extract${extract}. ${IF_PAGE_NOT_RECOGNISED}`;
|
|
284
|
+
return `${record.domain} is ready: run the extract again${extract}. ${IF_PAGE_NOT_RECOGNISED}`;
|
|
285
|
+
}
|
|
286
|
+
if (record.status === "failed")
|
|
287
|
+
return failureNextStep(record.domain, record.failure);
|
|
288
|
+
return `Preparing; it takes a few minutes. Check about every 30 seconds with: zenrows extract prepare status ${record.domain}`;
|
|
289
|
+
}
|