@readfetch/client 0.0.0-stage → 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,3 +1,105 @@
1
- # Temporary Holding Version
1
+ # @readfetch/client
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ x402 REST client for [readfetch.com](https://readfetch.com): every **published** catalog route plus **`/v1/feedback`** and **`/v1/claims`**.
4
+
5
+ Source: `tools/client-js`. GitHub: `./scripts/publish-client-js.sh`. Registry: `npm publish --access public` from this directory.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @readfetch/client
11
+ ```
12
+
13
+ ## Use
14
+
15
+ ```bash
16
+ export EVM_PRIVATE_KEY=0x… # not needed for probe / feedback / crawl_status
17
+ ```
18
+
19
+ ```javascript
20
+ import {
21
+ call,
22
+ probeWithMeta,
23
+ readfetchText,
24
+ readfetchMap,
25
+ readfetchCrawlStatus,
26
+ submitFeedback,
27
+ submitClaim,
28
+ } from "@readfetch/client";
29
+
30
+ const { data: check, requestId } = await probeWithMeta("https://example.com");
31
+ if (!check.challenge) {
32
+ await readfetchText("https://example.com");
33
+ // or: await call("text", { url: "https://example.com" });
34
+ }
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: "…" });
90
+ ```
91
+
92
+ `pay(name, body)` is an alias for `call()` on paid routes. Free routes (`probe`, `feedback`, `crawl_status`) use plain `fetch`.
93
+
94
+ ## Environment
95
+
96
+ | Variable | Default | Purpose |
97
+ |----------|---------|---------|
98
+ | `EVM_PRIVATE_KEY` / `X402_BUYER_KEY` | — | Required before paid calls |
99
+ | `READFETCH_BASE_URL` | `https://readfetch.com` | Production host only unless dev |
100
+ | `READFETCH_MAX_PAYMENT_USD` | `1` | Per-payment spend cap |
101
+ | `READFETCH_DEV=1` | off | Allow `localhost` / `127.0.0.1` + Sepolia (`eip155:84532`) |
102
+
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).
104
+
105
+ Feedback vs claims: [readfetch.com/docs#feedback](https://readfetch.com/docs#feedback).
package/config.mjs ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Base URL and env validation for readfetch REST clients.
3
+ */
4
+
5
+ const PRODUCTION_HOSTS = new Set(["readfetch.com", "www.readfetch.com"]);
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
+ }
26
+
27
+ export function normalizeHostname(hostname) {
28
+ return hostname.replace(/\.+$/, "").toLowerCase();
29
+ }
30
+
31
+ export function isProductionHost(hostname) {
32
+ return PRODUCTION_HOSTS.has(normalizeHostname(hostname));
33
+ }
34
+
35
+ export function isDevHost(hostname) {
36
+ return DEV_HOSTS.has(normalizeHostname(hostname));
37
+ }
38
+
39
+ /** @param {NodeJS.ProcessEnv} [env] */
40
+ export function readfetchBase(env = process.env) {
41
+ const raw = (env.READFETCH_BASE_URL || "https://readfetch.com").replace(/\/$/, "");
42
+ let url;
43
+ try {
44
+ url = new URL(raw);
45
+ } catch {
46
+ throw new Error("READFETCH_BASE_URL is not a valid URL");
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
+ }
52
+ if (isProductionHost(url.hostname)) {
53
+ if (url.protocol !== "https:") {
54
+ throw new Error("READFETCH_BASE_URL must use https for readfetch.com");
55
+ }
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);
63
+ }
64
+ if (env.READFETCH_DEV === "1" && isDevHost(url.hostname)) {
65
+ return formatOrigin(url);
66
+ }
67
+ throw new Error(
68
+ "READFETCH_BASE_URL must be https://readfetch.com (or localhost with READFETCH_DEV=1)",
69
+ );
70
+ }
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
+
85
+ /** @param {NodeJS.ProcessEnv} [env] */
86
+ export function parseMaxPaymentUsd(env = process.env) {
87
+ const raw = env.READFETCH_MAX_PAYMENT_USD?.trim() || "1";
88
+ if (!/^\d+(\.\d{1,6})?$/.test(raw)) {
89
+ throw new Error("READFETCH_MAX_PAYMENT_USD must be a decimal amount");
90
+ }
91
+ const n = Number(raw);
92
+ if (!Number.isFinite(n) || n <= 0 || n > 100) {
93
+ throw new Error("READFETCH_MAX_PAYMENT_USD must be between 0 and 100");
94
+ }
95
+ return raw;
96
+ }
97
+
98
+ /** @param {NodeJS.ProcessEnv} [env] */
99
+ export function x402Network(env = process.env) {
100
+ const base = readfetchBase(env);
101
+ try {
102
+ const host = new URL(base).hostname;
103
+ if (env.READFETCH_DEV === "1" && isDevHost(host)) {
104
+ return "eip155:84532";
105
+ }
106
+ } catch {
107
+ /* readfetchBase already validated */
108
+ }
109
+ return "eip155:8453";
110
+ }
111
+
112
+ /** @param {NodeJS.ProcessEnv} [env] */
113
+ export function loadAccountFromEnv(env = process.env) {
114
+ const raw = env.EVM_PRIVATE_KEY?.trim() || env.X402_BUYER_KEY?.trim();
115
+ if (!raw) {
116
+ throw new Error("Set EVM_PRIVATE_KEY (Base wallet with USDC)");
117
+ }
118
+ const hex = raw.startsWith("0x") ? raw : `0x${raw}`;
119
+ if (!/^0x[0-9a-fA-F]{64}$/.test(hex)) {
120
+ throw new Error("EVM_PRIVATE_KEY must be 64 hex bytes (optional 0x prefix)");
121
+ }
122
+ return hex.toLowerCase();
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 ADDED
@@ -0,0 +1,325 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { createHash } from "node:crypto";
3
+ import { wrapFetchWithPaymentFromConfig } from "@x402/fetch";
4
+ import { ExactEvmScheme } from "@x402/evm";
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";
13
+ import {
14
+ loadAccountFromEnv,
15
+ parseMaxPaymentUsd,
16
+ readfetchBase,
17
+ readfetchUrl,
18
+ x402Network,
19
+ } from "./config.mjs";
20
+ import { buyerAddress, paymentConfig, paymentFromResponse } from "./payment.mjs";
21
+
22
+ export {
23
+ isDevHost,
24
+ isProductionHost,
25
+ loadAccountFromEnv,
26
+ parseMaxPaymentUsd,
27
+ readfetchBase,
28
+ readfetchUrl,
29
+ x402Network,
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";
39
+
40
+ const TIMEOUT_MS = 120_000;
41
+
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
+ }
73
+
74
+ /** @type {Map<string, ReturnType<typeof wrapFetchWithPaymentFromConfig>>} */
75
+ const paidFetchByKey = new Map();
76
+
77
+ /** @param {NodeJS.ProcessEnv} [env] */
78
+ export function paidFetch(env = process.env) {
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, {
92
+ schemes: [{ network: x402Network(env), client: new ExactEvmScheme(account) }],
93
+ spendControls: { maxAmountPerPayment: `$${parseMaxPaymentUsd(env)}` },
94
+ });
95
+ paidFetchByKey.set(cacheKey, wrapped);
96
+ }
97
+ return wrapped;
98
+ }
99
+
100
+ /** Alias — `@x402/fetch` wrapper that signs EIP-3009 and retries on 402. */
101
+ export const createPaidFetch = paidFetch;
102
+
103
+ /** `x-request-id` from any readfetch JSON response (for `submitFeedback`). */
104
+ export function requestIdFromResponse(response) {
105
+ return response.headers.get("x-request-id") ?? response.headers.get("X-Request-Id");
106
+ }
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
+
231
+ /** Free route — call before paying for `/v1/text`. Returns body only. */
232
+ export async function probe(url, env = process.env) {
233
+ const { data } = await probeWithMeta(url, env);
234
+ return data;
235
+ }
236
+
237
+ /** Like `probe`, but includes `requestId` from response headers. */
238
+ export async function probeWithMeta(url, env = process.env) {
239
+ return callWithMeta("probe", { url }, { env });
240
+ }
241
+
242
+ /** POST /v1/feedback — free; bind with `requestId` from a prior call's `X-Request-Id`. */
243
+ export async function submitFeedback(requestId, category, message, env = process.env) {
244
+ const id = String(requestId ?? "").trim();
245
+ const cat = String(category ?? "").trim();
246
+ const msg = String(message ?? "").trim();
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
+ }
251
+ if (!FEEDBACK_CATEGORIES.has(cat)) {
252
+ throw new Error("category must be integration, docs, site, or other");
253
+ }
254
+ if (!msg || msg.length > 500) {
255
+ throw new Error("message is required (max 500 characters)");
256
+ }
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 });
267
+ }
268
+
269
+ /** POST /v1/text — x402 402 handling is automatic. */
270
+ export async function readfetchText(url, env = process.env) {
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 });
311
+ }
312
+
313
+ /** OpenAI Agents SDK — attach `execute` to your host's tool wrapper. */
314
+ export const readfetchTextToolDefinition = {
315
+ type: "function",
316
+ name: "readfetch_text",
317
+ description:
318
+ "Read a public URL and return main article text as Markdown with metadata (readfetch.com).",
319
+ parameters: {
320
+ type: "object",
321
+ required: ["url"],
322
+ properties: { url: { type: "string", format: "uri" } },
323
+ },
324
+ execute: async ({ url }) => readfetchText(url),
325
+ };
package/package.json CHANGED
@@ -1,6 +1,28 @@
1
1
  {
2
2
  "name": "@readfetch/client",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.11",
4
+ "description": "x402 REST client for readfetch.com (probe, text, …)",
5
+ "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/readfetch/client-js.git"
9
+ },
10
+ "exports": {
11
+ ".": "./index.mjs"
12
+ },
13
+ "files": ["index.mjs", "config.mjs", "endpoints.mjs", "payment.mjs", "README.md"],
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "engines": { "node": ">=20" },
18
+ "license": "MIT",
19
+ "scripts": {
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
+ },
23
+ "dependencies": {
24
+ "@x402/evm": "2.29.0",
25
+ "@x402/fetch": "2.29.0",
26
+ "viem": "2.57.13"
27
+ }
28
+ }
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
+ }