@readfetch/client 0.1.1 → 0.1.12
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 +73 -12
- package/config.mjs +46 -4
- package/endpoints.mjs +73 -0
- package/index.mjs +247 -38
- package/package.json +13 -5
- package/payment.mjs +53 -0
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @readfetch/client
|
|
2
2
|
|
|
3
|
-
x402 REST client for [readfetch.com](https://readfetch.com):
|
|
3
|
+
x402 REST client for [readfetch.com](https://readfetch.com): every **published** catalog route plus **`/v1/feedback`** and **`/v1/claims`**.
|
|
4
4
|
|
|
5
|
-
Source
|
|
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 {
|
|
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
|
-
|
|
32
|
+
await readfetchText("https://example.com");
|
|
33
|
+
// or: await call("text", { url: "https://example.com" });
|
|
26
34
|
}
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
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@0.1.12`](https://github.com/readfetch/mcp-x402-proxy).
|
|
43
104
|
|
|
44
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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.
|
|
3
|
+
"version": "0.1.12",
|
|
4
4
|
"description": "x402 REST client for readfetch.com (probe, text, …)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -10,15 +10,23 @@
|
|
|
10
10
|
"exports": {
|
|
11
11
|
".": "./index.mjs"
|
|
12
12
|
},
|
|
13
|
-
"files": [
|
|
13
|
+
"files": [
|
|
14
|
+
"index.mjs",
|
|
15
|
+
"config.mjs",
|
|
16
|
+
"endpoints.mjs",
|
|
17
|
+
"payment.mjs",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
14
20
|
"publishConfig": {
|
|
15
21
|
"access": "public"
|
|
16
22
|
},
|
|
17
|
-
"engines": {
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=20"
|
|
25
|
+
},
|
|
18
26
|
"license": "MIT",
|
|
19
27
|
"scripts": {
|
|
20
|
-
"test": "node test-static.mjs",
|
|
21
|
-
"prepublishOnly": "node --check index.mjs && node --check config.mjs && node test-static.mjs"
|
|
28
|
+
"test": "node test-static.mjs && node test-endpoints.mjs && node test-catalog-sync.mjs",
|
|
29
|
+
"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
30
|
},
|
|
23
31
|
"dependencies": {
|
|
24
32
|
"@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
|
+
}
|