@readfetch/client 0.1.1 → 0.1.11

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
@@ -1,8 +1,8 @@
1
1
  # @readfetch/client
2
2
 
3
- x402 REST client for [readfetch.com](https://readfetch.com): **`probe`** (free) and **`readfetchText`** (paid `/v1/text`).
3
+ x402 REST client for [readfetch.com](https://readfetch.com): every **published** catalog route plus **`/v1/feedback`** and **`/v1/claims`**.
4
4
 
5
- Source of truth in the private service checkout: `tools/client-js`. Published to GitHub with `./scripts/publish-client-js.sh`.
5
+ Source: `tools/client-js`. GitHub: `./scripts/publish-client-js.sh`. Registry: `npm publish --access public` from this directory.
6
6
 
7
7
  ## Install
8
8
 
@@ -13,21 +13,84 @@ npm install @readfetch/client
13
13
  ## Use
14
14
 
15
15
  ```bash
16
- export EVM_PRIVATE_KEY=0x… # not needed for probe()
16
+ export EVM_PRIVATE_KEY=0x… # not needed for probe / feedback / crawl_status
17
17
  ```
18
18
 
19
19
  ```javascript
20
- import { probe, probeWithMeta, readfetchText, submitFeedback } from "@readfetch/client";
20
+ import {
21
+ call,
22
+ probeWithMeta,
23
+ readfetchText,
24
+ readfetchMap,
25
+ readfetchCrawlStatus,
26
+ submitFeedback,
27
+ submitClaim,
28
+ } from "@readfetch/client";
21
29
 
22
30
  const { data: check, requestId } = await probeWithMeta("https://example.com");
23
- if (requestId) console.error(`X-Request-Id: ${requestId}`);
24
31
  if (!check.challenge) {
25
- const page = await readfetchText("https://example.com");
32
+ await readfetchText("https://example.com");
33
+ // or: await call("text", { url: "https://example.com" });
26
34
  }
27
- // Integrator issues (not billing):
28
- // await submitFeedback(requestId, "integration", "402 confused my parser");
35
+
36
+ await readfetchMap({ url: "https://example.com", max_urls: 20 });
37
+ const job = await readfetchCrawl({ url: "https://example.com", max_pages: 3 });
38
+ await readfetchCrawlStatus(job.id);
39
+
40
+ await submitFeedback(requestId, "integration", "402 confused my parser");
41
+ // await submitClaim({ receipt, reason, address, signature });
42
+ ```
43
+
44
+ ### Named helpers (same as `call(name, body)`)
45
+
46
+ | Name | Function | Paid |
47
+ |------|----------|------|
48
+ | `text` | `readfetchText(url \| body)` | yes |
49
+ | `map` | `readfetchMap(body)` | yes |
50
+ | `scrape` | `readfetchScrape(body)` | yes |
51
+ | `residential` | `readfetchResidential(body)` | yes |
52
+ | `fetch` | `readfetchFetch(body)` | yes |
53
+ | `batch` | `readfetchBatch(body)` | yes |
54
+ | `pdf` | `readfetchPdf(body)` | yes |
55
+ | `section` | `readfetchSection(body)` | yes |
56
+ | `diff` | `readfetchDiff(body)` | yes |
57
+ | `screenshot` | `readfetchScreenshot(body)` | yes |
58
+ | `search` | `readfetchSearch(body)` | yes |
59
+ | `crawl` | `readfetchCrawl(body)` | yes |
60
+ | `crawl_status` | `readfetchCrawlStatus(jobId)` | no (GET) |
61
+ | `probe` | `probe(url)` / `probeWithMeta(url)` | no |
62
+
63
+ Integrator: `submitFeedback`, `submitClaim`. Generic: `call("search", { query: "…" })`, `callWithMeta` for `X-Request-Id` and settlement headers.
64
+
65
+ ## Payment (x402)
66
+
67
+ Paid catalog routes use **`@x402/fetch`**: first request may return **402** with `PAYMENT-REQUIRED`; the client signs (EIP-3009) and retries automatically.
68
+
69
+ ```javascript
70
+ import {
71
+ paymentConfig,
72
+ paidFetch,
73
+ quote,
74
+ callWithMeta,
75
+ } from "@readfetch/client";
76
+
77
+ // No network — is the wallet configured?
78
+ console.log(paymentConfig()); // { baseUrl, network, maxPaymentUsd, buyerAddress, signingReady }
79
+
80
+ // Quote without a key (402 body + PAYMENT-REQUIRED header)
81
+ const q = await quote("text", { url: "https://example.com" });
82
+
83
+ // Paid call — signing + retry handled for you
84
+ const { data, requestId, payment } = await callWithMeta("text", { url: "https://example.com" });
85
+ // payment.receipt, payment.paymentResponse, payment.creditApplied on 200
86
+
87
+ // Custom fetch to readfetch URLs (same signer + spend cap)
88
+ const http = paidFetch();
89
+ await http("https://readfetch.com/v1/map", { method: "POST", body: "…" });
29
90
  ```
30
91
 
92
+ `pay(name, body)` is an alias for `call()` on paid routes. Free routes (`probe`, `feedback`, `crawl_status`) use plain `fetch`.
93
+
31
94
  ## Environment
32
95
 
33
96
  | Variable | Default | Purpose |
@@ -37,8 +100,6 @@ if (!check.challenge) {
37
100
  | `READFETCH_MAX_PAYMENT_USD` | `1` | Per-payment spend cap |
38
101
  | `READFETCH_DEV=1` | off | Allow `localhost` / `127.0.0.1` + Sepolia (`eip155:84532`) |
39
102
 
40
- Framework wrappers (LangChain.js, OpenAI Agents SDK): see [readfetch/readfetch examples](https://github.com/readfetch/readfetch/tree/main/examples).
41
-
42
- MCP hosts: use [`@readfetch/mcp-x402-proxy`](https://github.com/readfetch/mcp-x402-proxy) instead — they cannot attach `PAYMENT-SIGNATURE` on `tools/call`.
103
+ Framework examples: [readfetch/readfetch](https://github.com/readfetch/readfetch/tree/main/examples). MCP: [`@readfetch/mcp-x402-proxy`](https://github.com/readfetch/mcp-x402-proxy).
43
104
 
44
- **Feedback:** after a confusing call, `POST /v1/feedback` with the response's `X-Request-Id` (or MCP `readfetch_feedback`). Not for billing — use claims. See [readfetch.com/docs#feedback](https://readfetch.com/docs#feedback).
105
+ Feedback vs claims: [readfetch.com/docs#feedback](https://readfetch.com/docs#feedback).
package/config.mjs CHANGED
@@ -3,7 +3,26 @@
3
3
  */
4
4
 
5
5
  const PRODUCTION_HOSTS = new Set(["readfetch.com", "www.readfetch.com"]);
6
- const DEV_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
6
+ const DEV_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
7
+
8
+ /** @param {URL} url */
9
+ function assertNoUserinfo(url) {
10
+ if (url.username || url.password) {
11
+ throw new Error("READFETCH_BASE_URL must not include userinfo");
12
+ }
13
+ }
14
+
15
+ /** @param {URL} url */
16
+ function formatOrigin(url) {
17
+ if (!url.hostname) {
18
+ throw new Error("READFETCH_BASE_URL must include a hostname");
19
+ }
20
+ let origin = `${url.protocol}//${url.hostname}`;
21
+ if (url.port) {
22
+ origin += `:${url.port}`;
23
+ }
24
+ return origin;
25
+ }
7
26
 
8
27
  export function normalizeHostname(hostname) {
9
28
  return hostname.replace(/\.+$/, "").toLowerCase();
@@ -26,20 +45,43 @@ export function readfetchBase(env = process.env) {
26
45
  } catch {
27
46
  throw new Error("READFETCH_BASE_URL is not a valid URL");
28
47
  }
48
+ assertNoUserinfo(url);
49
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
50
+ throw new Error("READFETCH_BASE_URL must use http or https");
51
+ }
29
52
  if (isProductionHost(url.hostname)) {
30
53
  if (url.protocol !== "https:") {
31
54
  throw new Error("READFETCH_BASE_URL must use https for readfetch.com");
32
55
  }
33
- return raw;
56
+ if (url.pathname !== "" && url.pathname !== "/") {
57
+ throw new Error("READFETCH_BASE_URL must not include a path on readfetch.com");
58
+ }
59
+ if (url.port && url.port !== "443") {
60
+ throw new Error("READFETCH_BASE_URL must use default https port for readfetch.com");
61
+ }
62
+ return formatOrigin(url);
34
63
  }
35
64
  if (env.READFETCH_DEV === "1" && isDevHost(url.hostname)) {
36
- return raw;
65
+ return formatOrigin(url);
37
66
  }
38
67
  throw new Error(
39
68
  "READFETCH_BASE_URL must be https://readfetch.com (or localhost with READFETCH_DEV=1)",
40
69
  );
41
70
  }
42
71
 
72
+ /** @param {string} path — must start with `/` (from `endpointPath`) */
73
+ export function readfetchUrl(env = process.env, path) {
74
+ const base = readfetchBase(env);
75
+ if (!path.startsWith("/")) {
76
+ throw new Error("endpoint path must start with /");
77
+ }
78
+ const url = `${base}${path}`;
79
+ if (new URL(url).origin !== new URL(base).origin) {
80
+ throw new Error("request URL origin must match READFETCH_BASE_URL");
81
+ }
82
+ return url;
83
+ }
84
+
43
85
  /** @param {NodeJS.ProcessEnv} [env] */
44
86
  export function parseMaxPaymentUsd(env = process.env) {
45
87
  const raw = env.READFETCH_MAX_PAYMENT_USD?.trim() || "1";
@@ -77,5 +119,5 @@ export function loadAccountFromEnv(env = process.env) {
77
119
  if (!/^0x[0-9a-fA-F]{64}$/.test(hex)) {
78
120
  throw new Error("EVM_PRIVATE_KEY must be 64 hex bytes (optional 0x prefix)");
79
121
  }
80
- return hex;
122
+ return hex.toLowerCase();
81
123
  }
package/endpoints.mjs ADDED
@@ -0,0 +1,73 @@
1
+ /** Published routes — keep in sync with `catalog.json` (published + integrator). */
2
+
3
+ /** @typedef {{ name: string, method: string, path: string, free: boolean, accept?: number[] }} EndpointSpec */
4
+
5
+ /** Max length for `{id}` and other path param substitutions. */
6
+ export const MAX_PATH_PARAM_LEN = 512;
7
+
8
+ /** @type {EndpointSpec[]} */
9
+ export const CATALOG_ENDPOINTS = [
10
+ { name: "text", method: "POST", path: "/v1/text", free: false },
11
+ { name: "map", method: "POST", path: "/v1/map", free: false },
12
+ { name: "scrape", method: "POST", path: "/v1/scrape", free: false },
13
+ { name: "residential", method: "POST", path: "/v1/scrape/residential", free: false },
14
+ { name: "fetch", method: "POST", path: "/v1/fetch", free: false },
15
+ { name: "batch", method: "POST", path: "/v1/batch", free: false },
16
+ { name: "pdf", method: "POST", path: "/v1/pdf", free: false },
17
+ { name: "section", method: "POST", path: "/v1/section", free: false },
18
+ { name: "diff", method: "POST", path: "/v1/diff", free: false },
19
+ { name: "screenshot", method: "POST", path: "/v1/screenshot", free: false },
20
+ { name: "search", method: "POST", path: "/v1/search", free: false },
21
+ { name: "crawl", method: "POST", path: "/v1/crawl", free: false },
22
+ { name: "crawl_status", method: "GET", path: "/v1/crawl/{id}", free: true },
23
+ { name: "probe", method: "POST", path: "/v1/probe", free: true },
24
+ ];
25
+
26
+ /** @type {EndpointSpec[]} */
27
+ export const INTEGRATOR_ENDPOINTS = [
28
+ { name: "feedback", method: "POST", path: "/v1/feedback", free: true, accept: [202] },
29
+ { name: "claims", method: "POST", path: "/v1/claims", free: true },
30
+ ];
31
+
32
+ /** @type {Map<string, EndpointSpec>} */
33
+ export const BY_NAME = new Map(
34
+ [...CATALOG_ENDPOINTS, ...INTEGRATOR_ENDPOINTS].map((spec) => [spec.name, spec]),
35
+ );
36
+
37
+ /**
38
+ * @param {string} name
39
+ * @param {Record<string, string>} [pathParams]
40
+ */
41
+ export function endpointPath(name, pathParams = {}) {
42
+ const spec = BY_NAME.get(name);
43
+ if (!spec) {
44
+ throw new Error(`unknown endpoint \`${name}\`; known: ${[...BY_NAME.keys()].join(", ")}`);
45
+ }
46
+ let path = spec.path.replace(/\{(\w+)\}/g, (_, key) => {
47
+ const value = pathParams[key];
48
+ if (value === undefined || value === null) {
49
+ throw new Error(`\`${name}\` requires pathParams.${key}`);
50
+ }
51
+ const trimmed = String(value).trim();
52
+ if (!trimmed) {
53
+ throw new Error(`\`${name}\` requires pathParams.${key}`);
54
+ }
55
+ if (trimmed.length > MAX_PATH_PARAM_LEN) {
56
+ throw new Error(`\`${name}\` pathParams.${key} exceeds ${MAX_PATH_PARAM_LEN} characters`);
57
+ }
58
+ return encodeURIComponent(trimmed);
59
+ });
60
+ if (/\{/.test(path)) {
61
+ throw new Error(`\`${name}\` has unresolved path template`);
62
+ }
63
+ return path;
64
+ }
65
+
66
+ /** camelCase export: readfetchText, readfetchCrawlStatus, … */
67
+ export function exportName(name) {
68
+ const parts = name.split("_");
69
+ return (
70
+ "readfetch" +
71
+ parts.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("")
72
+ );
73
+ }
package/index.mjs CHANGED
@@ -1,12 +1,23 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { createHash } from "node:crypto";
1
3
  import { wrapFetchWithPaymentFromConfig } from "@x402/fetch";
2
4
  import { ExactEvmScheme } from "@x402/evm";
3
5
  import { privateKeyToAccount } from "viem/accounts";
6
+ import {
7
+ BY_NAME,
8
+ CATALOG_ENDPOINTS,
9
+ INTEGRATOR_ENDPOINTS,
10
+ endpointPath,
11
+ exportName,
12
+ } from "./endpoints.mjs";
4
13
  import {
5
14
  loadAccountFromEnv,
6
15
  parseMaxPaymentUsd,
7
16
  readfetchBase,
17
+ readfetchUrl,
8
18
  x402Network,
9
19
  } from "./config.mjs";
20
+ import { buyerAddress, paymentConfig, paymentFromResponse } from "./payment.mjs";
10
21
 
11
22
  export {
12
23
  isDevHost,
@@ -14,32 +25,209 @@ export {
14
25
  loadAccountFromEnv,
15
26
  parseMaxPaymentUsd,
16
27
  readfetchBase,
28
+ readfetchUrl,
17
29
  x402Network,
18
30
  } from "./config.mjs";
31
+ export { buyerAddress, paymentConfig, paymentFromResponse } from "./payment.mjs";
32
+ export {
33
+ BY_NAME,
34
+ CATALOG_ENDPOINTS,
35
+ INTEGRATOR_ENDPOINTS,
36
+ endpointPath,
37
+ exportName,
38
+ } from "./endpoints.mjs";
19
39
 
20
40
  const TIMEOUT_MS = 120_000;
21
41
 
22
42
  const FEEDBACK_CATEGORIES = new Set(["integration", "docs", "site", "other"]);
43
+ const MAX_FEEDBACK_REQUEST_ID_LEN = 256;
44
+
45
+ /** Max JSON body size accepted from readfetch (16 MiB). */
46
+ export const MAX_JSON_RESPONSE_BYTES = 16 * 1024 * 1024;
47
+
48
+ /** @param {string} text @param {number} status */
49
+ function parseJsonBody(text, status) {
50
+ if (!text) {
51
+ return {};
52
+ }
53
+ if (Buffer.byteLength(text, "utf8") > MAX_JSON_RESPONSE_BYTES) {
54
+ throw new Error(`${status}: response body exceeds ${MAX_JSON_RESPONSE_BYTES} bytes`);
55
+ }
56
+ try {
57
+ const parsed = JSON.parse(text);
58
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
59
+ throw new Error("expected JSON object");
60
+ }
61
+ return parsed;
62
+ } catch (error) {
63
+ const detail = error instanceof Error ? error.message : String(error);
64
+ throw new Error(`${status}: ${text.slice(0, 400)} (invalid JSON: ${detail})`);
65
+ }
66
+ }
67
+
68
+ /** @param {string} privateKey @param {NodeJS.ProcessEnv} env */
69
+ function paidFetchCacheKey(privateKey, env) {
70
+ const fingerprint = createHash("sha256").update(privateKey, "utf8").digest("hex");
71
+ return `${fingerprint}:${x402Network(env)}:${parseMaxPaymentUsd(env)}`;
72
+ }
23
73
 
24
- let fetchWithPayment;
74
+ /** @type {Map<string, ReturnType<typeof wrapFetchWithPaymentFromConfig>>} */
75
+ const paidFetchByKey = new Map();
25
76
 
26
77
  /** @param {NodeJS.ProcessEnv} [env] */
27
78
  export function paidFetch(env = process.env) {
28
- if (!fetchWithPayment) {
29
- const account = privateKeyToAccount(loadAccountFromEnv(env));
30
- fetchWithPayment = wrapFetchWithPaymentFromConfig(globalThis.fetch, {
79
+ let privateKey;
80
+ try {
81
+ privateKey = loadAccountFromEnv(env);
82
+ } catch (error) {
83
+ throw new Error(
84
+ `Paid calls need EVM_PRIVATE_KEY (Base USDC): ${error instanceof Error ? error.message : error}`,
85
+ );
86
+ }
87
+ const cacheKey = paidFetchCacheKey(privateKey, env);
88
+ let wrapped = paidFetchByKey.get(cacheKey);
89
+ if (!wrapped) {
90
+ const account = privateKeyToAccount(privateKey);
91
+ wrapped = wrapFetchWithPaymentFromConfig(globalThis.fetch, {
31
92
  schemes: [{ network: x402Network(env), client: new ExactEvmScheme(account) }],
32
93
  spendControls: { maxAmountPerPayment: `$${parseMaxPaymentUsd(env)}` },
33
94
  });
95
+ paidFetchByKey.set(cacheKey, wrapped);
34
96
  }
35
- return fetchWithPayment;
97
+ return wrapped;
36
98
  }
37
99
 
100
+ /** Alias — `@x402/fetch` wrapper that signs EIP-3009 and retries on 402. */
101
+ export const createPaidFetch = paidFetch;
102
+
38
103
  /** `x-request-id` from any readfetch JSON response (for `submitFeedback`). */
39
104
  export function requestIdFromResponse(response) {
40
105
  return response.headers.get("x-request-id") ?? response.headers.get("X-Request-Id");
41
106
  }
42
107
 
108
+ /** @param {string} text @param {number} status */
109
+ function assertTextSize(text, status) {
110
+ if (Buffer.byteLength(text, "utf8") > MAX_JSON_RESPONSE_BYTES) {
111
+ throw new Error(`${status}: response body exceeds ${MAX_JSON_RESPONSE_BYTES} bytes`);
112
+ }
113
+ }
114
+
115
+ /** @param {Response} response */
116
+ function assertResponseSize(response) {
117
+ const cl = response.headers.get("content-length");
118
+ if (cl === null || cl === "") {
119
+ return;
120
+ }
121
+ const n = Number(cl);
122
+ if (!Number.isFinite(n) || n < 0) {
123
+ return;
124
+ }
125
+ if (n > MAX_JSON_RESPONSE_BYTES) {
126
+ throw new Error(
127
+ `${response.status}: content-length ${n} exceeds ${MAX_JSON_RESPONSE_BYTES} bytes`,
128
+ );
129
+ }
130
+ }
131
+
132
+ /**
133
+ * @param {string} name — e.g. `text`, `map`, `probe`, `crawl_status`
134
+ * @param {object | null | undefined} body — JSON body (omit for GET `crawl_status`)
135
+ * @param {{ pathParams?: Record<string, string>, env?: NodeJS.ProcessEnv }} [options]
136
+ */
137
+ export async function call(name, body, options = {}) {
138
+ const { data } = await callWithMeta(name, body, options);
139
+ return data;
140
+ }
141
+
142
+ /**
143
+ * @param {string} name
144
+ * @param {object | null | undefined} body
145
+ * @param {{ pathParams?: Record<string, string>, env?: NodeJS.ProcessEnv }} [options]
146
+ */
147
+ export async function callWithMeta(name, body, options = {}) {
148
+ const spec = BY_NAME.get(name);
149
+ if (!spec) {
150
+ throw new Error(`unknown endpoint \`${name}\``);
151
+ }
152
+ const env = options.env ?? process.env;
153
+ const path = endpointPath(name, options.pathParams ?? {});
154
+ const url = readfetchUrl(env, path);
155
+ const method = spec.method;
156
+ /** @type {RequestInit} */
157
+ const init = { method, signal: AbortSignal.timeout(TIMEOUT_MS), headers: {} };
158
+ if (method !== "GET") {
159
+ init.headers["content-type"] = "application/json";
160
+ init.body = JSON.stringify(body ?? {});
161
+ }
162
+ const http = spec.free ? globalThis.fetch : paidFetch(env);
163
+ const response = await http(url, init);
164
+ assertResponseSize(response);
165
+ const text = await response.text();
166
+ assertTextSize(text, response.status);
167
+ const okStatuses = spec.accept?.length ? spec.accept : [200];
168
+ if (!okStatuses.includes(response.status)) {
169
+ throw new Error(`${response.status}: ${text.slice(0, 400)}`);
170
+ }
171
+ const data = parseJsonBody(text, response.status);
172
+ return {
173
+ data,
174
+ requestId: requestIdFromResponse(response),
175
+ payment: paymentFromResponse(response),
176
+ response,
177
+ };
178
+ }
179
+
180
+ /**
181
+ * Unpaid request — returns the 402 quote (`accepts`, `price_usd`, …). No wallet required.
182
+ * @param {string} name — paid catalog route only
183
+ */
184
+ export async function quote(name, body, options = {}) {
185
+ const spec = BY_NAME.get(name);
186
+ if (!spec) {
187
+ throw new Error(`unknown endpoint \`${name}\``);
188
+ }
189
+ if (spec.free) {
190
+ throw new Error(`\`${name}\` is free — use call() instead of quote()`);
191
+ }
192
+ const env = options.env ?? process.env;
193
+ const path = endpointPath(name, options.pathParams ?? {});
194
+ const url = readfetchUrl(env, path);
195
+ const method = spec.method;
196
+ /** @type {RequestInit} */
197
+ const init = {
198
+ method,
199
+ signal: AbortSignal.timeout(TIMEOUT_MS),
200
+ headers: { "content-type": "application/json" },
201
+ body: JSON.stringify(body ?? {}),
202
+ };
203
+ if (method === "GET") {
204
+ delete init.body;
205
+ delete init.headers["content-type"];
206
+ }
207
+ const response = await fetch(url, init);
208
+ assertResponseSize(response);
209
+ const text = await response.text();
210
+ assertTextSize(text, response.status);
211
+ if (response.status !== 402) {
212
+ throw new Error(`expected 402 quote, got ${response.status}: ${text.slice(0, 400)}`);
213
+ }
214
+ return {
215
+ status: 402,
216
+ body: parseJsonBody(text, 402),
217
+ paymentRequired:
218
+ response.headers.get("payment-required") ??
219
+ response.headers.get("PAYMENT-REQUIRED"),
220
+ requestId: requestIdFromResponse(response),
221
+ };
222
+ }
223
+
224
+ /**
225
+ * Paid route: sign via `@x402/fetch` and return JSON. Same as `call()` on paid endpoints.
226
+ */
227
+ export async function pay(name, body, options = {}) {
228
+ return call(name, body, options);
229
+ }
230
+
43
231
  /** Free route — call before paying for `/v1/text`. Returns body only. */
44
232
  export async function probe(url, env = process.env) {
45
233
  const { data } = await probeWithMeta(url, env);
@@ -48,17 +236,7 @@ export async function probe(url, env = process.env) {
48
236
 
49
237
  /** Like `probe`, but includes `requestId` from response headers. */
50
238
  export async function probeWithMeta(url, env = process.env) {
51
- const response = await fetch(`${readfetchBase(env)}/v1/probe`, {
52
- method: "POST",
53
- headers: { "content-type": "application/json" },
54
- body: JSON.stringify({ url }),
55
- signal: AbortSignal.timeout(TIMEOUT_MS),
56
- });
57
- if (!response.ok) {
58
- const text = await response.text();
59
- throw new Error(`${response.status}: ${text.slice(0, 400)}`);
60
- }
61
- return { data: await response.json(), requestId: requestIdFromResponse(response) };
239
+ return callWithMeta("probe", { url }, { env });
62
240
  }
63
241
 
64
242
  /** POST /v1/feedback — free; bind with `requestId` from a prior call's `X-Request-Id`. */
@@ -67,38 +245,69 @@ export async function submitFeedback(requestId, category, message, env = process
67
245
  const cat = String(category ?? "").trim();
68
246
  const msg = String(message ?? "").trim();
69
247
  if (!id) throw new Error("request_id is required");
248
+ if (id.length > MAX_FEEDBACK_REQUEST_ID_LEN) {
249
+ throw new Error(`request_id exceeds ${MAX_FEEDBACK_REQUEST_ID_LEN} characters`);
250
+ }
70
251
  if (!FEEDBACK_CATEGORIES.has(cat)) {
71
252
  throw new Error("category must be integration, docs, site, or other");
72
253
  }
73
254
  if (!msg || msg.length > 500) {
74
255
  throw new Error("message is required (max 500 characters)");
75
256
  }
76
- const response = await fetch(`${readfetchBase(env)}/v1/feedback`, {
77
- method: "POST",
78
- headers: { "content-type": "application/json" },
79
- body: JSON.stringify({ request_id: id, category: cat, message: msg }),
80
- signal: AbortSignal.timeout(TIMEOUT_MS),
81
- });
82
- const text = await response.text();
83
- if (response.status !== 202) {
84
- throw new Error(`${response.status}: ${text.slice(0, 400)}`);
85
- }
86
- return JSON.parse(text);
257
+ return call(
258
+ "feedback",
259
+ { request_id: id, category: cat, message: msg },
260
+ { env },
261
+ );
262
+ }
263
+
264
+ /** POST /v1/claims — paid delivery dispute (receipt + signature). Not integrator feedback. */
265
+ export async function submitClaim(claim, env = process.env) {
266
+ return call("claims", claim, { env });
87
267
  }
88
268
 
89
269
  /** POST /v1/text — x402 402 handling is automatic. */
90
270
  export async function readfetchText(url, env = process.env) {
91
- const response = await paidFetch(env)(`${readfetchBase(env)}/v1/text`, {
92
- method: "POST",
93
- headers: { "content-type": "application/json" },
94
- body: JSON.stringify({ url }),
95
- signal: AbortSignal.timeout(TIMEOUT_MS),
96
- });
97
- if (!response.ok) {
98
- const text = await response.text();
99
- throw new Error(`${response.status}: ${text.slice(0, 400)}`);
100
- }
101
- return response.json();
271
+ const body = typeof url === "string" ? { url } : url;
272
+ return call("text", body, { env });
273
+ }
274
+
275
+ export function readfetchMap(body, env = process.env) {
276
+ return call("map", body, { env });
277
+ }
278
+ export function readfetchScrape(body, env = process.env) {
279
+ return call("scrape", body, { env });
280
+ }
281
+ export function readfetchResidential(body, env = process.env) {
282
+ return call("residential", body, { env });
283
+ }
284
+ export function readfetchFetch(body, env = process.env) {
285
+ return call("fetch", body, { env });
286
+ }
287
+ export function readfetchBatch(body, env = process.env) {
288
+ return call("batch", body, { env });
289
+ }
290
+ export function readfetchPdf(body, env = process.env) {
291
+ return call("pdf", body, { env });
292
+ }
293
+ export function readfetchSection(body, env = process.env) {
294
+ return call("section", body, { env });
295
+ }
296
+ export function readfetchDiff(body, env = process.env) {
297
+ return call("diff", body, { env });
298
+ }
299
+ export function readfetchScreenshot(body, env = process.env) {
300
+ return call("screenshot", body, { env });
301
+ }
302
+ export function readfetchSearch(body, env = process.env) {
303
+ return call("search", body, { env });
304
+ }
305
+ export function readfetchCrawl(body, env = process.env) {
306
+ return call("crawl", body, { env });
307
+ }
308
+ /** GET /v1/crawl/{id} — poll async crawl (free). */
309
+ export function readfetchCrawlStatus(jobId, env = process.env) {
310
+ return call("crawl_status", null, { pathParams: { id: jobId }, env });
102
311
  }
103
312
 
104
313
  /** OpenAI Agents SDK — attach `execute` to your host's tool wrapper. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@readfetch/client",
3
- "version": "0.1.1",
3
+ "version": "0.1.11",
4
4
  "description": "x402 REST client for readfetch.com (probe, text, …)",
5
5
  "type": "module",
6
6
  "repository": {
@@ -10,15 +10,15 @@
10
10
  "exports": {
11
11
  ".": "./index.mjs"
12
12
  },
13
- "files": ["index.mjs", "config.mjs", "README.md"],
13
+ "files": ["index.mjs", "config.mjs", "endpoints.mjs", "payment.mjs", "README.md"],
14
14
  "publishConfig": {
15
15
  "access": "public"
16
16
  },
17
17
  "engines": { "node": ">=20" },
18
18
  "license": "MIT",
19
19
  "scripts": {
20
- "test": "node test-static.mjs",
21
- "prepublishOnly": "node --check index.mjs && node --check config.mjs && node test-static.mjs"
20
+ "test": "node test-static.mjs && node test-endpoints.mjs && node test-catalog-sync.mjs",
21
+ "prepublishOnly": "node --check index.mjs && node --check config.mjs && node --check endpoints.mjs && node --check payment.mjs && node test-static.mjs && node test-endpoints.mjs && node test-catalog-sync.mjs"
22
22
  },
23
23
  "dependencies": {
24
24
  "@x402/evm": "2.29.0",
package/payment.mjs ADDED
@@ -0,0 +1,53 @@
1
+ /** x402 payment helpers — quotes, receipts, wallet config. */
2
+
3
+ import { privateKeyToAccount } from "viem/accounts";
4
+ import {
5
+ loadAccountFromEnv,
6
+ parseMaxPaymentUsd,
7
+ readfetchBase,
8
+ x402Network,
9
+ } from "./config.mjs";
10
+
11
+ /** Settlement / receipt headers on a paid 200. */
12
+ export function paymentFromResponse(response) {
13
+ const receipt =
14
+ response.headers.get("receipt") ?? response.headers.get("RECEIPT");
15
+ const paymentResponse =
16
+ response.headers.get("payment-response") ??
17
+ response.headers.get("PAYMENT-RESPONSE") ??
18
+ response.headers.get("x-payment-response") ??
19
+ response.headers.get("X-PAYMENT-RESPONSE");
20
+ const creditApplied =
21
+ response.headers.get("x-credit-applied") ??
22
+ response.headers.get("X-CREDIT-APPLIED");
23
+ const paymentRequired =
24
+ response.headers.get("payment-required") ??
25
+ response.headers.get("PAYMENT-REQUIRED");
26
+ return {
27
+ receipt: receipt || null,
28
+ paymentResponse: paymentResponse || null,
29
+ paymentRequired: paymentRequired || null,
30
+ creditApplied: creditApplied || null,
31
+ };
32
+ }
33
+
34
+ /** Payer address when `EVM_PRIVATE_KEY` is set. */
35
+ export function buyerAddress(env = process.env) {
36
+ try {
37
+ return privateKeyToAccount(loadAccountFromEnv(env)).address;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ /** Wallet + network + spend cap — no network I/O. */
44
+ export function paymentConfig(env = process.env) {
45
+ const address = buyerAddress(env);
46
+ return {
47
+ baseUrl: readfetchBase(env),
48
+ network: x402Network(env),
49
+ maxPaymentUsd: parseMaxPaymentUsd(env),
50
+ buyerAddress: address,
51
+ signingReady: address !== null,
52
+ };
53
+ }