crw-sdk 0.22.0 → 0.24.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 CHANGED
@@ -58,14 +58,16 @@ CRW_LOCAL=1 node app.js
58
58
  | `changeTrackingDiff(cur, prev?)` | Diff vs a prior snapshot | HTTP |
59
59
  | `close()` | Shut down the local subprocess | — |
60
60
 
61
- ¹ Local search needs a SearXNG URL configured on the engine.
61
+ ¹ Local search needs a search backend configured on the engine.
62
62
 
63
63
  ```ts
64
- // Structured extraction:
65
- const data = await crw.extract({
64
+ // Structured extraction (async job → per-URL results array):
65
+ const results = await crw.extract({
66
66
  urls: ["https://example.com"],
67
67
  schema: { type: "object", properties: { title: { type: "string" } } },
68
68
  });
69
+ // results: [{ url, status, data, error, llmUsage }]
70
+ for (const r of results) if (r.status === "completed") console.log(r.url, r.data);
69
71
 
70
72
  // Parse a PDF:
71
73
  import { readFileSync } from "node:fs";
@@ -95,7 +95,7 @@ class CrwClient {
95
95
  return result.links ?? [];
96
96
  }
97
97
  /**
98
- * Works in both modes; local mode needs a SearXNG URL configured on the engine.
98
+ * Works in both modes; local mode needs a search backend configured on the engine.
99
99
  */
100
100
  async search(query, opts = {}) {
101
101
  const { limit = 5, lang, tbs, sources, categories, scrapeOptions, ...rest } = opts;
@@ -111,8 +111,36 @@ class CrwClient {
111
111
  if (scrapeOptions)
112
112
  args.scrapeOptions = scrapeOptions;
113
113
  Object.assign(args, rest);
114
- if (this.apiUrl)
115
- return this.httpPost("/v1/search", args);
114
+ if (this.apiUrl) {
115
+ // `data` IS the results, in whatever shape the caller asked for: a flat
116
+ // array, a `{ web, news }` object when `sources` is set, or the engine's
117
+ // `{ results, answer, … }` when you point the client at a self-hosted
118
+ // server. Return it untouched — reshaping it here silently emptied both the
119
+ // grouped and the self-hosted cases.
120
+ //
121
+ // `answer` / `citations` / `llmUsage` ride BESIDE `data` on the managed API,
122
+ // and the old `data` unwrap dropped them: you could pay for an AI answer and
123
+ // have no way to read it. Attach them non-enumerably, so `results.answer`
124
+ // works while `for…of`, spread and JSON.stringify keep seeing exactly the
125
+ // array they saw before.
126
+ const raw = await this.httpRequest("POST", "/v1/search", args, {
127
+ raw: true,
128
+ });
129
+ const data = (raw.data ?? []);
130
+ if (data !== null && typeof data === "object") {
131
+ for (const key of ["answer", "citations", "llmUsage"]) {
132
+ if (raw[key] !== undefined) {
133
+ Object.defineProperty(data, key, {
134
+ value: raw[key],
135
+ enumerable: false,
136
+ configurable: true,
137
+ writable: true,
138
+ });
139
+ }
140
+ }
141
+ }
142
+ return data;
143
+ }
116
144
  return this.localTransport().toolCall("crw_search", args);
117
145
  }
118
146
  /**
@@ -170,29 +198,45 @@ class CrwClient {
170
198
  Object.assign(args, rest);
171
199
  return this.localTransport().toolCall("crw_parse_file", args);
172
200
  }
173
- /** Structured LLM extraction across URLs (HTTP mode only). */
201
+ /**
202
+ * Native multi-URL structured extraction (HTTP mode only). Starts an async
203
+ * `/v1/extract` job and polls until complete, returning a per-URL results
204
+ * array (`[{ url, status, data, error, llmUsage }]`) in request order.
205
+ */
174
206
  async extract(opts) {
175
207
  if (!this.apiUrl)
176
208
  throw new errors_js_1.CrwError(httpOnlyHint("extract", "LLM extract job endpoint"));
177
- const { urls, prompt, schema, systemPrompt, pollInterval = 2, timeout = 120 } = opts;
209
+ const { urls, prompt, schema, basis, llmApiKey, llmProvider, llmModel, pollInterval = 2, timeout = 120 } = opts;
178
210
  const body = { urls: [...urls] };
179
211
  if (prompt !== undefined)
180
212
  body.prompt = prompt;
181
213
  if (schema !== undefined)
182
214
  body.schema = schema;
183
- if (systemPrompt !== undefined)
184
- body.systemPrompt = systemPrompt;
185
- const start = await this.httpRequest("POST", "/v2/extract", body, { raw: true });
215
+ if (basis !== undefined)
216
+ body.basis = basis;
217
+ if (llmApiKey !== undefined)
218
+ body.llmApiKey = llmApiKey;
219
+ if (llmProvider !== undefined)
220
+ body.llmProvider = llmProvider;
221
+ if (llmModel !== undefined)
222
+ body.llmModel = llmModel;
223
+ const start = await this.httpRequest("POST", "/v1/extract", body, { raw: true });
186
224
  const jobId = start.id;
187
- if (!jobId)
188
- throw new errors_js_1.CrwError(`extract did not return job ID: ${JSON.stringify(start)}`);
225
+ // The managed API answers synchronously: the extraction is already finished and
226
+ // the payload is in this first response, with no job to poll. Only the async
227
+ // path hands back an `id`. Demanding one made every managed extract() throw.
228
+ if (!jobId) {
229
+ if (Array.isArray(start.results))
230
+ return start.results;
231
+ throw new errors_js_1.CrwError(`extract returned neither a job id nor results: ${JSON.stringify(start)}`);
232
+ }
189
233
  const deadline = Date.now() + timeout * 1000;
190
234
  for (;;) {
191
235
  if (Date.now() > deadline)
192
236
  throw new errors_js_1.CrwTimeoutError(`Extract ${jobId} timed out after ${timeout}s`);
193
- const status = await this.httpRequest("GET", `/v2/extract/${jobId}`, undefined, { raw: true, checkSuccess: false });
237
+ const status = await this.httpRequest("GET", `/v1/extract/${jobId}`, undefined, { raw: true, checkSuccess: false });
194
238
  if (status.status === "completed")
195
- return status.data ?? {};
239
+ return status.results ?? [];
196
240
  if (status.status === "failed")
197
241
  throw new errors_js_1.CrwError(`Extract failed: ${status.error ?? "unknown"}`);
198
242
  await sleep(pollInterval * 1000);
@@ -17,7 +17,7 @@ export declare class CrwClient {
17
17
  crawl(url: string, opts?: CrawlOptions): Promise<CrawlResult>;
18
18
  map(url: string, opts?: MapOptions): Promise<string[]>;
19
19
  /**
20
- * Works in both modes; local mode needs a SearXNG URL configured on the engine.
20
+ * Works in both modes; local mode needs a search backend configured on the engine.
21
21
  */
22
22
  search(query: string, opts?: SearchOptions): Promise<SearchResult>;
23
23
  /**
@@ -37,7 +37,11 @@ export declare class CrwClient {
37
37
  * Parse a document (PDF) into markdown / structured JSON. Works in both modes.
38
38
  */
39
39
  parseFile(content: Uint8Array, opts?: ParseFileOptions): Promise<ParseResult>;
40
- /** Structured LLM extraction across URLs (HTTP mode only). */
40
+ /**
41
+ * Native multi-URL structured extraction (HTTP mode only). Starts an async
42
+ * `/v1/extract` job and polls until complete, returning a per-URL results
43
+ * array (`[{ url, status, data, error, llmUsage }]`) in request order.
44
+ */
41
45
  extract(opts: ExtractOptions): Promise<ExtractResult>;
42
46
  /** Scrape many URLs in one async batch job (HTTP mode only). */
43
47
  batchScrape(urls: string[], opts?: BatchScrapeOptions): Promise<BatchResult>;
@@ -92,7 +92,7 @@ export class CrwClient {
92
92
  return result.links ?? [];
93
93
  }
94
94
  /**
95
- * Works in both modes; local mode needs a SearXNG URL configured on the engine.
95
+ * Works in both modes; local mode needs a search backend configured on the engine.
96
96
  */
97
97
  async search(query, opts = {}) {
98
98
  const { limit = 5, lang, tbs, sources, categories, scrapeOptions, ...rest } = opts;
@@ -108,8 +108,36 @@ export class CrwClient {
108
108
  if (scrapeOptions)
109
109
  args.scrapeOptions = scrapeOptions;
110
110
  Object.assign(args, rest);
111
- if (this.apiUrl)
112
- return this.httpPost("/v1/search", args);
111
+ if (this.apiUrl) {
112
+ // `data` IS the results, in whatever shape the caller asked for: a flat
113
+ // array, a `{ web, news }` object when `sources` is set, or the engine's
114
+ // `{ results, answer, … }` when you point the client at a self-hosted
115
+ // server. Return it untouched — reshaping it here silently emptied both the
116
+ // grouped and the self-hosted cases.
117
+ //
118
+ // `answer` / `citations` / `llmUsage` ride BESIDE `data` on the managed API,
119
+ // and the old `data` unwrap dropped them: you could pay for an AI answer and
120
+ // have no way to read it. Attach them non-enumerably, so `results.answer`
121
+ // works while `for…of`, spread and JSON.stringify keep seeing exactly the
122
+ // array they saw before.
123
+ const raw = await this.httpRequest("POST", "/v1/search", args, {
124
+ raw: true,
125
+ });
126
+ const data = (raw.data ?? []);
127
+ if (data !== null && typeof data === "object") {
128
+ for (const key of ["answer", "citations", "llmUsage"]) {
129
+ if (raw[key] !== undefined) {
130
+ Object.defineProperty(data, key, {
131
+ value: raw[key],
132
+ enumerable: false,
133
+ configurable: true,
134
+ writable: true,
135
+ });
136
+ }
137
+ }
138
+ }
139
+ return data;
140
+ }
113
141
  return this.localTransport().toolCall("crw_search", args);
114
142
  }
115
143
  /**
@@ -167,29 +195,45 @@ export class CrwClient {
167
195
  Object.assign(args, rest);
168
196
  return this.localTransport().toolCall("crw_parse_file", args);
169
197
  }
170
- /** Structured LLM extraction across URLs (HTTP mode only). */
198
+ /**
199
+ * Native multi-URL structured extraction (HTTP mode only). Starts an async
200
+ * `/v1/extract` job and polls until complete, returning a per-URL results
201
+ * array (`[{ url, status, data, error, llmUsage }]`) in request order.
202
+ */
171
203
  async extract(opts) {
172
204
  if (!this.apiUrl)
173
205
  throw new CrwError(httpOnlyHint("extract", "LLM extract job endpoint"));
174
- const { urls, prompt, schema, systemPrompt, pollInterval = 2, timeout = 120 } = opts;
206
+ const { urls, prompt, schema, basis, llmApiKey, llmProvider, llmModel, pollInterval = 2, timeout = 120 } = opts;
175
207
  const body = { urls: [...urls] };
176
208
  if (prompt !== undefined)
177
209
  body.prompt = prompt;
178
210
  if (schema !== undefined)
179
211
  body.schema = schema;
180
- if (systemPrompt !== undefined)
181
- body.systemPrompt = systemPrompt;
182
- const start = await this.httpRequest("POST", "/v2/extract", body, { raw: true });
212
+ if (basis !== undefined)
213
+ body.basis = basis;
214
+ if (llmApiKey !== undefined)
215
+ body.llmApiKey = llmApiKey;
216
+ if (llmProvider !== undefined)
217
+ body.llmProvider = llmProvider;
218
+ if (llmModel !== undefined)
219
+ body.llmModel = llmModel;
220
+ const start = await this.httpRequest("POST", "/v1/extract", body, { raw: true });
183
221
  const jobId = start.id;
184
- if (!jobId)
185
- throw new CrwError(`extract did not return job ID: ${JSON.stringify(start)}`);
222
+ // The managed API answers synchronously: the extraction is already finished and
223
+ // the payload is in this first response, with no job to poll. Only the async
224
+ // path hands back an `id`. Demanding one made every managed extract() throw.
225
+ if (!jobId) {
226
+ if (Array.isArray(start.results))
227
+ return start.results;
228
+ throw new CrwError(`extract returned neither a job id nor results: ${JSON.stringify(start)}`);
229
+ }
186
230
  const deadline = Date.now() + timeout * 1000;
187
231
  for (;;) {
188
232
  if (Date.now() > deadline)
189
233
  throw new CrwTimeoutError(`Extract ${jobId} timed out after ${timeout}s`);
190
- const status = await this.httpRequest("GET", `/v2/extract/${jobId}`, undefined, { raw: true, checkSuccess: false });
234
+ const status = await this.httpRequest("GET", `/v1/extract/${jobId}`, undefined, { raw: true, checkSuccess: false });
191
235
  if (status.status === "completed")
192
- return status.data ?? {};
236
+ return status.results ?? [];
193
237
  if (status.status === "failed")
194
238
  throw new CrwError(`Extract failed: ${status.error ?? "unknown"}`);
195
239
  await sleep(pollInterval * 1000);
@@ -25,9 +25,53 @@ export interface ScrapeOptions {
25
25
  waitFor?: number;
26
26
  /** JSON Schema for structured LLM extraction (auto-adds the `json` format). */
27
27
  jsonSchema?: Json;
28
+ /**
29
+ * Return per-field evidence alongside `json`. Every top-level scalar property
30
+ * of `jsonSchema` comes back as a {@link Basis}. Requires `jsonSchema`.
31
+ */
32
+ basis?: boolean;
28
33
  /** Any other engine scrape option, passed through verbatim. */
29
34
  [key: string]: unknown;
30
35
  }
36
+ /** How well an extracted field is attributed to its source. */
37
+ export type FieldStatus = "supported" | "unverified" | "unsupported" | "notFound";
38
+ /** The citation backing an extracted value. Every field is server-produced. */
39
+ export interface EvidenceCitation {
40
+ /** The document the engine fetched. Never a model-supplied url. */
41
+ url: string;
42
+ title?: string;
43
+ /**
44
+ * Verbatim span from the canonical source text. Absent when the span could
45
+ * not be established (`status: "unverified"`).
46
+ */
47
+ excerpt?: string;
48
+ /** `sha256:<hex>` of the exact text sent to the model. */
49
+ sourceHash: string;
50
+ /** Which text `sourceHash` covers. `"llmInput"` for extraction basis. */
51
+ sourceTextKind: string;
52
+ }
53
+ /**
54
+ * Per-field extraction evidence. Emitted only for top-level scalar schema
55
+ * properties, and only when the request set `basis: true`.
56
+ *
57
+ * `status` is honest: a field whose attribution could not be verified is marked
58
+ * `unverified`/`unsupported` rather than given a fabricated citation.
59
+ * `citations` is empty exactly when `status` is `unsupported` or `notFound`;
60
+ * `value` is `null` exactly when `status` is `notFound`.
61
+ */
62
+ export interface Basis {
63
+ basisVersion: number;
64
+ field: string;
65
+ value: unknown | null;
66
+ status: FieldStatus;
67
+ confidence?: "low" | "medium" | "high";
68
+ citations: EvidenceCitation[];
69
+ }
70
+ /** A coded reason a field's attribution was downgraded. Never upstream text. */
71
+ export interface BasisWarning {
72
+ field: string;
73
+ code: string;
74
+ }
31
75
  export interface CrawlOptions {
32
76
  maxDepth?: number;
33
77
  maxPages?: number;
@@ -60,7 +104,15 @@ export interface ExtractOptions {
60
104
  urls: string[];
61
105
  prompt?: string;
62
106
  schema?: Json;
63
- systemPrompt?: string;
107
+ /**
108
+ * Return per-field evidence: each result carries a `basis` array, one
109
+ * {@link Basis} per top-level scalar schema property. Requires `schema`.
110
+ */
111
+ basis?: boolean;
112
+ /** BYOK: use your own LLM key/provider/model instead of the server's. */
113
+ llmApiKey?: string;
114
+ llmProvider?: string;
115
+ llmModel?: string;
64
116
  pollInterval?: number;
65
117
  timeout?: number;
66
118
  }
@@ -80,7 +132,8 @@ export type ScrapeResult = Json;
80
132
  export type CrawlResult = Json[];
81
133
  export type SearchResult = Json | Json[];
82
134
  export type ParseResult = Json;
83
- export type ExtractResult = Json;
135
+ /** Native `/v1/extract` returns one result object per URL, in request order. */
136
+ export type ExtractResult = Json[];
84
137
  export type BatchResult = Json[];
85
138
  export type Capabilities = Json;
86
139
  export type DiffResult = Json;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crw-sdk",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "description": "TypeScript/JavaScript SDK for CRW — scrape, crawl, map, search, parse, and extract any website",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/us/crw",