@expandai/sdk 0.19.0 → 0.20.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 +130 -4
- package/build/dts/AppendixMarkers.d.ts +30 -0
- package/build/dts/AppendixMarkers.d.ts.map +1 -0
- package/build/dts/Client.d.ts +41 -6
- package/build/dts/Client.d.ts.map +1 -1
- package/build/dts/Crawls.d.ts +568 -0
- package/build/dts/Crawls.d.ts.map +1 -0
- package/build/dts/EnvProxyHttpClient.d.ts +28 -0
- package/build/dts/EnvProxyHttpClient.d.ts.map +1 -0
- package/build/dts/FetchJson.d.ts +444 -37
- package/build/dts/FetchJson.d.ts.map +1 -1
- package/build/dts/Generated.d.ts +1545 -788
- package/build/dts/Generated.d.ts.map +1 -1
- package/build/dts/HttpClientHelpers.d.ts +13 -0
- package/build/dts/HttpClientHelpers.d.ts.map +1 -1
- package/build/dts/Internal.d.ts +6 -4
- package/build/dts/Internal.d.ts.map +1 -1
- package/build/dts/ParseErrorBody.d.ts +26 -0
- package/build/dts/ParseErrorBody.d.ts.map +1 -0
- package/build/dts/Playground.d.ts +20 -0
- package/build/dts/Playground.d.ts.map +1 -1
- package/build/dts/RetryAfter.d.ts +40 -0
- package/build/dts/RetryAfter.d.ts.map +1 -0
- package/build/dts/Service.d.ts +94 -45
- package/build/dts/Service.d.ts.map +1 -1
- package/build/dts/StreamPage.d.ts +10 -0
- package/build/dts/StreamPage.d.ts.map +1 -0
- package/build/dts/index.d.ts +9 -2
- package/build/dts/index.d.ts.map +1 -1
- package/build/esm/AppendixMarkers.js +307 -0
- package/build/esm/AppendixMarkers.js.map +1 -0
- package/build/esm/Client.js +96 -15
- package/build/esm/Client.js.map +1 -1
- package/build/esm/Crawls.js +786 -0
- package/build/esm/Crawls.js.map +1 -0
- package/build/esm/EnvProxyHttpClient.js +55 -0
- package/build/esm/EnvProxyHttpClient.js.map +1 -0
- package/build/esm/FetchJson.js +48 -22
- package/build/esm/FetchJson.js.map +1 -1
- package/build/esm/Generated.js +540 -112
- package/build/esm/Generated.js.map +1 -1
- package/build/esm/HttpClientHelpers.js +36 -1
- package/build/esm/HttpClientHelpers.js.map +1 -1
- package/build/esm/Internal.js +140 -33
- package/build/esm/Internal.js.map +1 -1
- package/build/esm/ParseErrorBody.js +41 -0
- package/build/esm/ParseErrorBody.js.map +1 -0
- package/build/esm/Playground.js +38 -0
- package/build/esm/Playground.js.map +1 -1
- package/build/esm/RetryAfter.js +104 -0
- package/build/esm/RetryAfter.js.map +1 -0
- package/build/esm/Service.js +96 -17
- package/build/esm/Service.js.map +1 -1
- package/build/esm/StreamPage.js +46 -0
- package/build/esm/StreamPage.js.map +1 -0
- package/build/esm/index.js +5 -1
- package/build/esm/index.js.map +1 -1
- package/package.json +8 -2
- package/src/AppendixMarkers.ts +370 -0
- package/src/Client.ts +131 -16
- package/src/Crawls.ts +1387 -0
- package/src/EnvProxyHttpClient.ts +71 -0
- package/src/FetchJson.ts +63 -25
- package/src/Generated.ts +738 -162
- package/src/HttpClientHelpers.ts +48 -1
- package/src/Internal.ts +163 -105
- package/src/ParseErrorBody.ts +42 -0
- package/src/Playground.ts +48 -0
- package/src/RetryAfter.ts +138 -0
- package/src/Service.ts +208 -25
- package/src/StreamPage.ts +47 -0
- package/src/index.ts +29 -3
package/src/Crawls.ts
ADDED
|
@@ -0,0 +1,1387 @@
|
|
|
1
|
+
import { BackoffDefaults, stream as durableStream, FetchError, type JsonBatch } from '@durable-streams/client'
|
|
2
|
+
import { Duration } from 'effect'
|
|
3
|
+
import {
|
|
4
|
+
ExpandClientApiError,
|
|
5
|
+
ExpandClientConnectionError,
|
|
6
|
+
ExpandClientError,
|
|
7
|
+
ExpandClientTimeoutError,
|
|
8
|
+
} from './Error.js'
|
|
9
|
+
import { isRetryable } from './Internal.js'
|
|
10
|
+
import { MAX_RETRY_AFTER } from './RetryAfter.js'
|
|
11
|
+
import { readStreamPage } from './StreamPage.js'
|
|
12
|
+
|
|
13
|
+
export type CrawlOffset = string
|
|
14
|
+
export type CrawlId = string
|
|
15
|
+
export type CrawlPageId = string
|
|
16
|
+
|
|
17
|
+
export type CrawlJsonValue =
|
|
18
|
+
| string
|
|
19
|
+
| number
|
|
20
|
+
| boolean
|
|
21
|
+
| null
|
|
22
|
+
| readonly CrawlJsonValue[]
|
|
23
|
+
| { readonly [key: string]: CrawlJsonValue }
|
|
24
|
+
|
|
25
|
+
export interface CrawlSource {
|
|
26
|
+
readonly url: string
|
|
27
|
+
readonly kind?: 'seed' | 'sitemap'
|
|
28
|
+
/** Which pages end up in the results. Non-matching pages may still be opened to follow their links. */
|
|
29
|
+
readonly include?: readonly string[]
|
|
30
|
+
/** Pages the crawler never opens. */
|
|
31
|
+
readonly exclude?: readonly string[]
|
|
32
|
+
readonly maxPages?: number
|
|
33
|
+
/**
|
|
34
|
+
* Clicks from `url`: 0 is the URL alone, 1 adds the pages it links to, the site's sitemap and
|
|
35
|
+
* search results. A sitemap source's own listings count as depth 0. Defaults to 50.
|
|
36
|
+
*/
|
|
37
|
+
readonly maxDepth?: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface CrawlChangePolicy {
|
|
41
|
+
readonly ignore?: readonly string[]
|
|
42
|
+
readonly volatile?: readonly string[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface CrawlSyncPolicy {
|
|
46
|
+
/** How often every page is checked for changes, such as `24h` or `7d`. */
|
|
47
|
+
readonly freshness: string
|
|
48
|
+
readonly budget?: { readonly rendersPerDay?: number }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface CrawlRequest {
|
|
52
|
+
readonly sources: readonly (string | CrawlSource)[]
|
|
53
|
+
readonly formats?: readonly ('markdown' | 'stateJson' | 'html')[]
|
|
54
|
+
readonly maxPages?: number
|
|
55
|
+
readonly changePolicy?: CrawlChangePolicy
|
|
56
|
+
readonly sync?: CrawlSyncPolicy
|
|
57
|
+
/** A label for the crawl, 1–120 characters. Every crawl the request creates gets it. */
|
|
58
|
+
readonly name?: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface CrawlPageMeta {
|
|
62
|
+
readonly title?: string
|
|
63
|
+
readonly description?: string
|
|
64
|
+
readonly canonicalUrl?: string
|
|
65
|
+
readonly language?: string
|
|
66
|
+
readonly pageType: string
|
|
67
|
+
readonly author?: string
|
|
68
|
+
readonly publishedAt?: string
|
|
69
|
+
readonly updatedAt?: string
|
|
70
|
+
readonly image?: string
|
|
71
|
+
readonly openGraph?: Readonly<Record<string, string>>
|
|
72
|
+
readonly twitter?: Readonly<Record<string, string>>
|
|
73
|
+
/**
|
|
74
|
+
* Present when the capture behind this page ran at reduced fidelity, naming what was
|
|
75
|
+
* reduced (`bestEffort`, `skipped-modelInputTooLarge`, `hfc-local-fallback`, …). The
|
|
76
|
+
* page and its content are real; the field says how much of the extraction ran.
|
|
77
|
+
*/
|
|
78
|
+
readonly reducedCapture?: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface CrawlEvidence {
|
|
82
|
+
readonly key: string
|
|
83
|
+
readonly kind: 'inline' | 'network'
|
|
84
|
+
readonly role: string
|
|
85
|
+
/** Evidence relevance weight. Higher means more relevant; not a probability, so it can exceed 1. */
|
|
86
|
+
readonly score: number
|
|
87
|
+
readonly url?: string
|
|
88
|
+
readonly method?: string
|
|
89
|
+
readonly body?: CrawlJsonValue
|
|
90
|
+
readonly bodyUrl?: string
|
|
91
|
+
readonly approxTokens?: number
|
|
92
|
+
readonly keys?: readonly string[]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface CrawlStateJson {
|
|
96
|
+
readonly captureId: string
|
|
97
|
+
readonly evidence: readonly CrawlEvidence[]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface CrawlPageContent {
|
|
101
|
+
readonly markdown?: string
|
|
102
|
+
readonly stateJson?: CrawlStateJson
|
|
103
|
+
readonly html?: string
|
|
104
|
+
readonly truncated: readonly ('markdown' | 'stateJson' | 'html')[]
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface CrawlEventBase {
|
|
108
|
+
readonly offset: CrawlOffset
|
|
109
|
+
readonly id: string
|
|
110
|
+
readonly observedAt: string
|
|
111
|
+
readonly crawlId: CrawlId
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface CrawlPageAdded extends CrawlEventBase {
|
|
115
|
+
readonly type: 'page.added'
|
|
116
|
+
readonly pageId: CrawlPageId
|
|
117
|
+
readonly url: string
|
|
118
|
+
readonly captureId: string
|
|
119
|
+
readonly discoveredBy: 'seed' | 'sitemap' | 'link' | 'search'
|
|
120
|
+
readonly depth: number
|
|
121
|
+
readonly meta: CrawlPageMeta
|
|
122
|
+
readonly page: CrawlPageContent
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface CrawlPageChanged extends CrawlEventBase {
|
|
126
|
+
readonly type: 'page.changed'
|
|
127
|
+
readonly pageId: CrawlPageId
|
|
128
|
+
readonly url: string
|
|
129
|
+
readonly captureId: string
|
|
130
|
+
readonly previousCaptureId: string
|
|
131
|
+
readonly changed: { readonly content: boolean; readonly stateJson: boolean; readonly extraction: boolean }
|
|
132
|
+
readonly diff: {
|
|
133
|
+
readonly stateJson: Readonly<Record<string, { readonly from?: CrawlJsonValue; readonly to?: CrawlJsonValue }>>
|
|
134
|
+
readonly markdown: string
|
|
135
|
+
readonly truncated: boolean
|
|
136
|
+
}
|
|
137
|
+
readonly meta: CrawlPageMeta
|
|
138
|
+
readonly page: CrawlPageContent
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface CrawlPageRemoved extends CrawlEventBase {
|
|
142
|
+
readonly type: 'page.removed'
|
|
143
|
+
readonly pageId: CrawlPageId
|
|
144
|
+
readonly url: string
|
|
145
|
+
readonly previousCaptureId: string
|
|
146
|
+
readonly evidence: {
|
|
147
|
+
readonly kind: 'http-404' | 'http-410' | 'content-gone' | 'redirected-away'
|
|
148
|
+
readonly checks: readonly {
|
|
149
|
+
readonly captureId: string
|
|
150
|
+
readonly observedAt: string
|
|
151
|
+
readonly status: number
|
|
152
|
+
readonly finalUrl: string
|
|
153
|
+
}[]
|
|
154
|
+
readonly sitemapDisappeared: boolean
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface CrawlPageFailed extends CrawlEventBase {
|
|
159
|
+
readonly type: 'page.failed'
|
|
160
|
+
readonly url: string
|
|
161
|
+
readonly reason:
|
|
162
|
+
| 'blocked'
|
|
163
|
+
| 'not-found'
|
|
164
|
+
| 'timeout'
|
|
165
|
+
| 'error'
|
|
166
|
+
| 'robots-disallowed'
|
|
167
|
+
| 'robots-unreachable'
|
|
168
|
+
| 'out-of-scope'
|
|
169
|
+
/** The precise cause behind `reason`, when the check knew one (`quality-degraded`, `http-503`, …). */
|
|
170
|
+
readonly detail?: string
|
|
171
|
+
readonly retriesExhausted: boolean
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export type CrawlEvent = CrawlPageAdded | CrawlPageChanged | CrawlPageRemoved | CrawlPageFailed
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* One batch of URL transitions on the activity stream (`GET /v1/crawls/:id/activity`): what was
|
|
178
|
+
* discovered, admitted, claimed, rendered or deferred, which sitemaps and robots files resolved,
|
|
179
|
+
* and the run lifecycle. No content; the page stream (`events`) carries that. `eventsOffset` is a
|
|
180
|
+
* lower bound on the page stream: every page event at or below it committed before this one.
|
|
181
|
+
*/
|
|
182
|
+
export interface CrawlActivityEvent {
|
|
183
|
+
readonly offset: CrawlOffset
|
|
184
|
+
readonly id: string
|
|
185
|
+
readonly crawlId: CrawlId
|
|
186
|
+
readonly runId: string | null
|
|
187
|
+
/** ISO date-time. */
|
|
188
|
+
readonly observedAt: string
|
|
189
|
+
/** `discovery.batch`, `admission.batch`, `claim.batch`, `render.settled`, `sitemap.fetched`, `sitemap.failed`, `robots.resolved`, `search.resolved`, `alias.batch`, `run.started`, `run.discovery-closed`, `run.finished`, `run.canceled`, … */
|
|
190
|
+
readonly type: string
|
|
191
|
+
readonly eventsOffset: CrawlOffset
|
|
192
|
+
readonly payload: Record<string, unknown>
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export type CrawlAdmissionReason = 'no-fleet-snapshot' | 'no-fleet-capacity' | 'billing-blocked' | 'disabled' | 'none'
|
|
196
|
+
|
|
197
|
+
export interface CrawlAdmission {
|
|
198
|
+
readonly paused: boolean
|
|
199
|
+
readonly reason: CrawlAdmissionReason
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The canonical source configuration a crawl was created with. */
|
|
203
|
+
export interface CrawlConfigSummary {
|
|
204
|
+
readonly sources: readonly {
|
|
205
|
+
readonly url: string
|
|
206
|
+
readonly kind: 'seed' | 'sitemap'
|
|
207
|
+
readonly include: readonly string[]
|
|
208
|
+
readonly exclude: readonly string[]
|
|
209
|
+
readonly maxPages?: number
|
|
210
|
+
readonly maxDepth: number
|
|
211
|
+
}[]
|
|
212
|
+
/** How the include and exclude entries are read: RE2 searched in the path, or path globs. */
|
|
213
|
+
readonly patternSyntax: 're2' | 'glob'
|
|
214
|
+
readonly formats: readonly ('markdown' | 'stateJson' | 'html')[]
|
|
215
|
+
readonly maxPages: number
|
|
216
|
+
readonly changePolicy: { readonly ignore: readonly string[]; readonly volatile: readonly string[] }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The most recent run of a crawl, whatever its outcome. */
|
|
220
|
+
export interface CrawlRunSummary {
|
|
221
|
+
readonly kind: 'INITIAL' | 'MANUAL' | 'DISCOVERY'
|
|
222
|
+
readonly status: 'RUNNING' | 'DONE' | 'FAILED' | 'CANCELED'
|
|
223
|
+
/** ISO date-time. */
|
|
224
|
+
readonly startedAt: string
|
|
225
|
+
/** ISO date-time; absent while the run is still going. */
|
|
226
|
+
readonly completedAt?: string
|
|
227
|
+
/** `stalled` when the run was stopped because nothing settled for the stall timeout (an hour). */
|
|
228
|
+
readonly endReason?: 'stalled'
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export interface CrawlStatus {
|
|
232
|
+
readonly id: CrawlId
|
|
233
|
+
readonly state: 'crawling' | 'done' | 'syncing' | 'failed' | 'paused' | 'canceled'
|
|
234
|
+
/** ISO date-time the crawl was created. */
|
|
235
|
+
readonly createdAt?: string
|
|
236
|
+
/** Registrable domain the crawl is scoped to. */
|
|
237
|
+
readonly domain?: string
|
|
238
|
+
/** The crawl's label; absent when it is shown by its domain. */
|
|
239
|
+
readonly name?: string
|
|
240
|
+
/** ISO date-time the crawl was archived. Archived crawls are canceled and left out of `list()`. */
|
|
241
|
+
readonly archivedAt?: string
|
|
242
|
+
/** ISO date-time retention deletes an archived crawl, unless it is unarchived first. */
|
|
243
|
+
readonly deletesAt?: string
|
|
244
|
+
readonly pages: number
|
|
245
|
+
readonly failed: number
|
|
246
|
+
/** How many of `failed` were skips: URLs given up after one attempt so their slot could move on. */
|
|
247
|
+
readonly skipped?: number
|
|
248
|
+
readonly config?: CrawlConfigSummary
|
|
249
|
+
readonly run?: CrawlRunSummary
|
|
250
|
+
readonly sync?: CrawlSyncPolicy
|
|
251
|
+
readonly freshness?: {
|
|
252
|
+
readonly pagesPastDue: number
|
|
253
|
+
readonly maxOverdue: string
|
|
254
|
+
readonly probeMisses: number
|
|
255
|
+
readonly causes: readonly { readonly kind: string; readonly pages: number }[]
|
|
256
|
+
}
|
|
257
|
+
readonly coverage: {
|
|
258
|
+
readonly complete: boolean
|
|
259
|
+
readonly pendingAtCap: number
|
|
260
|
+
readonly sourcePartial: number
|
|
261
|
+
readonly causes: readonly string[]
|
|
262
|
+
}
|
|
263
|
+
readonly progress: {
|
|
264
|
+
readonly queued: number
|
|
265
|
+
readonly inFlight: number
|
|
266
|
+
readonly done: number
|
|
267
|
+
/** Page work alone; `queued` and `inFlight` also count robots and sitemap operations. */
|
|
268
|
+
readonly pages?: { readonly queued: number; readonly inFlight: number; readonly skipped?: number }
|
|
269
|
+
}
|
|
270
|
+
/** Only meaningful while work is queued: why nothing is being rendered right now. */
|
|
271
|
+
readonly admission?: CrawlAdmission
|
|
272
|
+
/** Trailing 24 hours; `budgetPerDay` is null when the sync policy sets no render budget. */
|
|
273
|
+
readonly renders?: { readonly usedToday: number; readonly budgetPerDay: number | null }
|
|
274
|
+
/** Renders settled in each of the last 60 minutes, oldest first, and the latest settle time. Single-crawl status only. */
|
|
275
|
+
readonly throughput?: { readonly perMinute: ReadonlyArray<number>; readonly lastSettledAt?: string }
|
|
276
|
+
readonly offset: CrawlOffset
|
|
277
|
+
/** The activity stream's tail; absent for a crawl that has no activity yet. */
|
|
278
|
+
readonly activityOffset?: CrawlOffset
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export interface CrawlReadOptions {
|
|
282
|
+
readonly from?: CrawlOffset
|
|
283
|
+
readonly live?: boolean
|
|
284
|
+
readonly signal?: AbortSignal
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export interface MergedCrawlReadOptions {
|
|
288
|
+
readonly from?: Readonly<Record<CrawlId, CrawlOffset>>
|
|
289
|
+
readonly live?: boolean
|
|
290
|
+
readonly signal?: AbortSignal
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export interface CrawlListOptions {
|
|
294
|
+
/** 1..200, defaults to 50. */
|
|
295
|
+
readonly limit?: number
|
|
296
|
+
/** Lists archived crawls instead of the rest. */
|
|
297
|
+
readonly archived?: boolean
|
|
298
|
+
readonly signal?: AbortSignal
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export interface CrawlListResponse {
|
|
302
|
+
readonly crawls: CrawlStatus[]
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export interface CrawlCreateOptions {
|
|
306
|
+
readonly idempotencyKey: string
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export interface CrawlRunOptions {
|
|
310
|
+
readonly idempotencyKey: string
|
|
311
|
+
/** Recover an existing command, or fail without creating a replacement run. */
|
|
312
|
+
readonly replayOnly?: boolean
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export interface CrawlDoneResult {
|
|
316
|
+
readonly lastOffset: CrawlOffset
|
|
317
|
+
readonly status: CrawlStatus
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export class CrawlClientError extends ExpandClientError {
|
|
321
|
+
readonly name: string = 'CrawlClientError'
|
|
322
|
+
constructor(
|
|
323
|
+
message: string,
|
|
324
|
+
readonly code?: string,
|
|
325
|
+
readonly status?: number,
|
|
326
|
+
readonly retryable = false,
|
|
327
|
+
) {
|
|
328
|
+
super(message)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** API refusal with the Crawl error code and original response envelope. */
|
|
333
|
+
export class CrawlClientApiError extends ExpandClientApiError {
|
|
334
|
+
constructor(
|
|
335
|
+
message: string,
|
|
336
|
+
readonly code: string | undefined,
|
|
337
|
+
status: number,
|
|
338
|
+
readonly retryable = false,
|
|
339
|
+
body?: unknown,
|
|
340
|
+
) {
|
|
341
|
+
super(message, { status, body })
|
|
342
|
+
this.name = 'CrawlClientApiError'
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export class CrawlOffsetExpiredError extends CrawlClientApiError {
|
|
347
|
+
readonly name: string = 'CrawlOffsetExpiredError'
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
interface CrawlTransportOptions {
|
|
351
|
+
readonly apiKey: string
|
|
352
|
+
readonly baseUrl: string
|
|
353
|
+
readonly fetch?: typeof globalThis.fetch
|
|
354
|
+
readonly timeoutMs?: number
|
|
355
|
+
readonly maxRetries?: number
|
|
356
|
+
readonly signal?: AbortSignal
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function transportSignal(transport: CrawlTransportOptions, signal?: AbortSignal): AbortSignal | undefined {
|
|
360
|
+
return transport.signal === undefined
|
|
361
|
+
? signal
|
|
362
|
+
: AbortSignal.any([transport.signal, ...(signal === undefined ? [] : [signal])])
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export interface CrawlProjectionOptions {
|
|
366
|
+
readonly signal?: AbortSignal
|
|
367
|
+
readonly timeoutMs?: number
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export interface CrawlListPage<T> {
|
|
371
|
+
readonly items: readonly T[]
|
|
372
|
+
readonly nextCursor?: string
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Await one page, iterate individual items, or use pages() for bounded batches. */
|
|
376
|
+
export class CrawlList<T> implements AsyncIterable<T>, PromiseLike<CrawlListPage<T>> {
|
|
377
|
+
private firstPage?: Promise<CrawlListPage<T>>
|
|
378
|
+
constructor(
|
|
379
|
+
private readonly fetchPage: (cursor?: string) => Promise<CrawlListPage<T>>,
|
|
380
|
+
private readonly cursor?: string,
|
|
381
|
+
private readonly signal?: AbortSignal,
|
|
382
|
+
) {}
|
|
383
|
+
|
|
384
|
+
private first(): Promise<CrawlListPage<T>> {
|
|
385
|
+
if (this.signal?.aborted === true) return Promise.reject(abortError(this.signal))
|
|
386
|
+
this.firstPage ??= this.fetchPage(this.cursor)
|
|
387
|
+
return this.firstPage
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
then<TResult1 = CrawlListPage<T>, TResult2 = never>(
|
|
391
|
+
onfulfilled?: ((value: CrawlListPage<T>) => TResult1 | PromiseLike<TResult1>) | null,
|
|
392
|
+
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
|
|
393
|
+
): PromiseLike<TResult1 | TResult2> {
|
|
394
|
+
return this.first().then(onfulfilled, onrejected)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async *pages(): AsyncGenerator<CrawlListPage<T>> {
|
|
398
|
+
let page = await this.first()
|
|
399
|
+
const visited = new Set<string>(this.cursor === undefined ? [] : [this.cursor])
|
|
400
|
+
while (true) {
|
|
401
|
+
if (signalIsAborted(this.signal)) throw abortError(this.signal)
|
|
402
|
+
yield page
|
|
403
|
+
if (page.nextCursor === undefined) return
|
|
404
|
+
if (visited.has(page.nextCursor)) throw new CrawlClientError('The server repeated a pagination cursor.')
|
|
405
|
+
visited.add(page.nextCursor)
|
|
406
|
+
page = await this.fetchPage(page.nextCursor)
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async *[Symbol.asyncIterator](): AsyncGenerator<T> {
|
|
411
|
+
for await (const page of this.pages())
|
|
412
|
+
for (const item of page.items) {
|
|
413
|
+
if (signalIsAborted(this.signal)) throw abortError(this.signal)
|
|
414
|
+
yield item
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async collect(options: { readonly limit: number }): Promise<T[]> {
|
|
419
|
+
if (!Number.isSafeInteger(options.limit) || options.limit < 1)
|
|
420
|
+
throw new CrawlClientError('collect limit must be positive.')
|
|
421
|
+
const items: T[] = []
|
|
422
|
+
for await (const item of this) {
|
|
423
|
+
items.push(item)
|
|
424
|
+
if (items.length >= options.limit) break
|
|
425
|
+
}
|
|
426
|
+
return items
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** A discovered URL in any admission state (`GET /v1/crawls/:id/urls`), the snapshot half of the URL view. */
|
|
431
|
+
export interface CrawlUrl {
|
|
432
|
+
readonly id: string
|
|
433
|
+
readonly url: string
|
|
434
|
+
readonly state: 'pending' | 'candidate' | 'reserved' | 'admitted' | 'traversal'
|
|
435
|
+
readonly sourceId: string
|
|
436
|
+
readonly depth: number
|
|
437
|
+
readonly discoveredBy: 'seed' | 'sitemap' | 'link' | 'search'
|
|
438
|
+
/** ISO date-time. */
|
|
439
|
+
readonly discoveredAt: string
|
|
440
|
+
/** Apply an activity event to this row only when `event.offset > activitySeq`. */
|
|
441
|
+
readonly activitySeq: CrawlOffset
|
|
442
|
+
readonly pageId?: string
|
|
443
|
+
/** Why the URL is where it is (`canceled`, `out-of-scope`, `skipped`, …). */
|
|
444
|
+
readonly reason?: string
|
|
445
|
+
readonly excludedReason?: string
|
|
446
|
+
/** What the render told us when `reason` is `skipped` (`http-500`, `timeout`). */
|
|
447
|
+
readonly skipDetail?: string
|
|
448
|
+
/** ISO date-time: when a skipped URL may be rearmed by a later run. */
|
|
449
|
+
readonly availableAt?: string
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export interface CrawlUrlListPage {
|
|
453
|
+
readonly urls: readonly CrawlUrl[]
|
|
454
|
+
readonly nextCursor?: string
|
|
455
|
+
readonly truncated: boolean
|
|
456
|
+
/** The activity stream's tail, read before the rows: tail `crawl.activity({ from })` from here. */
|
|
457
|
+
readonly activityOffset: CrawlOffset
|
|
458
|
+
/** Rows per state for the whole crawl under the same `source` and `search` filters, not just this page. */
|
|
459
|
+
readonly counts: Readonly<Record<string, number>>
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export interface CrawlCatalogPage {
|
|
463
|
+
readonly id: string
|
|
464
|
+
readonly url: string
|
|
465
|
+
readonly status: 'live' | 'removed'
|
|
466
|
+
readonly latestCaptureId?: string
|
|
467
|
+
readonly changedAt?: string
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export interface CrawlCurrentPage {
|
|
471
|
+
readonly id: string
|
|
472
|
+
readonly url: string
|
|
473
|
+
readonly status: 'live' | 'removed'
|
|
474
|
+
readonly captureId?: string
|
|
475
|
+
readonly meta?: CrawlPageMeta
|
|
476
|
+
readonly page?: CrawlPageContent
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export interface CrawlArtifactLinks {
|
|
480
|
+
readonly markdown: string
|
|
481
|
+
readonly html: string
|
|
482
|
+
readonly stateJson: string
|
|
483
|
+
readonly screenshot?: { readonly url: string; readonly fullPage: boolean }
|
|
484
|
+
readonly replay: string
|
|
485
|
+
readonly playground: string
|
|
486
|
+
readonly assets: string
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export interface CrawlPageVersion {
|
|
490
|
+
readonly captureId: string
|
|
491
|
+
readonly observedAt: string
|
|
492
|
+
readonly offset: CrawlOffset
|
|
493
|
+
readonly contentSha256: string
|
|
494
|
+
readonly http: { readonly status: number; readonly finalUrl: string }
|
|
495
|
+
readonly quality: 'complete' | 'best-effort' | 'degraded'
|
|
496
|
+
readonly meta: CrawlPageMeta
|
|
497
|
+
readonly artifacts: CrawlArtifactLinks
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export interface CrawlCaptureContent {
|
|
501
|
+
readonly captureId: string
|
|
502
|
+
readonly meta: CrawlPageMeta
|
|
503
|
+
readonly page: CrawlPageContent
|
|
504
|
+
readonly artifacts: CrawlArtifactLinks
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function projectionQuery(path: string, values: Readonly<Record<string, string | number | undefined>>): string {
|
|
508
|
+
const query = new URLSearchParams()
|
|
509
|
+
for (const [key, value] of Object.entries(values)) if (value !== undefined) query.set(key, String(value))
|
|
510
|
+
return query.size === 0 ? path : `${path}?${query}`
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async function projectionResponse(
|
|
514
|
+
transport: CrawlTransportOptions,
|
|
515
|
+
path: string,
|
|
516
|
+
options: CrawlProjectionOptions,
|
|
517
|
+
): Promise<Response> {
|
|
518
|
+
const target = new URL(absoluteUrl(transport.baseUrl, path))
|
|
519
|
+
const base = new URL(transport.baseUrl)
|
|
520
|
+
if (
|
|
521
|
+
target.origin !== base.origin ||
|
|
522
|
+
target.username !== '' ||
|
|
523
|
+
target.password !== '' ||
|
|
524
|
+
!target.pathname.startsWith('/v1/')
|
|
525
|
+
) {
|
|
526
|
+
throw new CrawlClientError('Projection URLs must belong to the configured Expand API.')
|
|
527
|
+
}
|
|
528
|
+
const timeoutMs = options.timeoutMs ?? transport.timeoutMs ?? 60_000
|
|
529
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0)
|
|
530
|
+
throw new CrawlClientError('Projection timeoutMs must be a positive integer.')
|
|
531
|
+
const deadline = AbortSignal.timeout(timeoutMs)
|
|
532
|
+
const callerSignal = transportSignal(transport, options.signal)
|
|
533
|
+
const signal = callerSignal === undefined ? deadline : AbortSignal.any([callerSignal, deadline])
|
|
534
|
+
if (signal.aborted) throw abortError(signal)
|
|
535
|
+
const response = await (transport.fetch ?? globalThis.fetch)(target, {
|
|
536
|
+
headers: { 'x-expand-api-key': transport.apiKey },
|
|
537
|
+
signal,
|
|
538
|
+
redirect: 'error',
|
|
539
|
+
})
|
|
540
|
+
if (!response.ok) throw await apiError(response)
|
|
541
|
+
return response
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function projectionJson<T>(
|
|
545
|
+
transport: CrawlTransportOptions,
|
|
546
|
+
path: string,
|
|
547
|
+
options: CrawlProjectionOptions,
|
|
548
|
+
): Promise<T> {
|
|
549
|
+
// Mirrors the typed API contract, like requestJson; arbitrary body reads use unknown.
|
|
550
|
+
return (await (await projectionResponse(transport, path, options)).json()) as unknown as T
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
export class CrawlCaptureResource {
|
|
554
|
+
constructor(
|
|
555
|
+
readonly id: string,
|
|
556
|
+
private readonly transport: CrawlTransportOptions,
|
|
557
|
+
) {}
|
|
558
|
+
get(options: CrawlProjectionOptions = {}): Promise<CrawlCaptureContent> {
|
|
559
|
+
return projectionJson(this.transport, `/v1/captures/${encodeURIComponent(this.id)}`, options)
|
|
560
|
+
}
|
|
561
|
+
stateJson(options: CrawlProjectionOptions = {}): Promise<CrawlStateJson> {
|
|
562
|
+
return projectionJson(this.transport, `/v1/captures/${encodeURIComponent(this.id)}/state-json`, options)
|
|
563
|
+
}
|
|
564
|
+
evidence(key: string, options: CrawlProjectionOptions = {}): Promise<CrawlJsonValue> {
|
|
565
|
+
return projectionJson(
|
|
566
|
+
this.transport,
|
|
567
|
+
`/v1/captures/${encodeURIComponent(this.id)}/state-json/${encodeURIComponent(key)}`,
|
|
568
|
+
options,
|
|
569
|
+
)
|
|
570
|
+
}
|
|
571
|
+
async markdown(options: CrawlProjectionOptions = {}): Promise<string> {
|
|
572
|
+
return (
|
|
573
|
+
await projectionResponse(this.transport, `/v1/captures/${encodeURIComponent(this.id)}/markdown`, options)
|
|
574
|
+
).text()
|
|
575
|
+
}
|
|
576
|
+
async html(options: CrawlProjectionOptions = {}): Promise<string> {
|
|
577
|
+
return (
|
|
578
|
+
await projectionResponse(this.transport, `/v1/captures/${encodeURIComponent(this.id)}/html`, options)
|
|
579
|
+
).text()
|
|
580
|
+
}
|
|
581
|
+
playgroundUrl(): string {
|
|
582
|
+
return `https://expand.land/s/${encodeURIComponent(this.id)}`
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
export class CrawlPageResource {
|
|
587
|
+
constructor(
|
|
588
|
+
readonly id: string,
|
|
589
|
+
private readonly transport: CrawlTransportOptions,
|
|
590
|
+
) {}
|
|
591
|
+
versions(
|
|
592
|
+
options: CrawlProjectionOptions & { readonly limit?: number; readonly before?: string; readonly at?: string } = {},
|
|
593
|
+
): CrawlList<CrawlPageVersion> {
|
|
594
|
+
return new CrawlList(
|
|
595
|
+
async (before) => {
|
|
596
|
+
const result = await projectionJson<{ readonly versions: CrawlPageVersion[]; readonly nextCursor?: string }>(
|
|
597
|
+
this.transport,
|
|
598
|
+
projectionQuery(`/v1/pages/${encodeURIComponent(this.id)}/versions`, {
|
|
599
|
+
before,
|
|
600
|
+
at: options.at,
|
|
601
|
+
limit: options.limit,
|
|
602
|
+
}),
|
|
603
|
+
options,
|
|
604
|
+
)
|
|
605
|
+
return { items: result.versions, nextCursor: options.at === undefined ? result.nextCursor : undefined }
|
|
606
|
+
},
|
|
607
|
+
options.before,
|
|
608
|
+
transportSignal(this.transport, options.signal),
|
|
609
|
+
)
|
|
610
|
+
}
|
|
611
|
+
diff(
|
|
612
|
+
options: CrawlProjectionOptions & { readonly from: string; readonly to: string },
|
|
613
|
+
): Promise<CrawlPageChanged['diff']> {
|
|
614
|
+
return projectionJson(
|
|
615
|
+
this.transport,
|
|
616
|
+
projectionQuery(`/v1/pages/${encodeURIComponent(this.id)}/diff`, { from: options.from, to: options.to }),
|
|
617
|
+
options,
|
|
618
|
+
)
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** Shares the Crawl transport without exposing its API key to arbitrary body URLs. */
|
|
623
|
+
export function makeCrawlProjectionsClient(transport: CrawlTransportOptions) {
|
|
624
|
+
return {
|
|
625
|
+
captures(id: string): CrawlCaptureResource {
|
|
626
|
+
if (id.length === 0) throw new CrawlClientError('Capture id must not be empty.')
|
|
627
|
+
return new CrawlCaptureResource(id, transport)
|
|
628
|
+
},
|
|
629
|
+
pages(id: string): CrawlPageResource {
|
|
630
|
+
if (id.length === 0) throw new CrawlClientError('Page id must not be empty.')
|
|
631
|
+
return new CrawlPageResource(id, transport)
|
|
632
|
+
},
|
|
633
|
+
fetchJson(url: string, options: CrawlProjectionOptions = {}): Promise<unknown> {
|
|
634
|
+
return projectionJson(transport, url, options)
|
|
635
|
+
},
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
interface CreatedCrawlResponse {
|
|
640
|
+
readonly id: CrawlId
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function requireIdempotencyKey(value: string | undefined, operation: string): string {
|
|
644
|
+
if (value === undefined || value.length < 1 || value.length > 255) {
|
|
645
|
+
throw new CrawlClientError(`${operation} requires an idempotencyKey between 1 and 255 characters.`)
|
|
646
|
+
}
|
|
647
|
+
return value
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
|
651
|
+
return typeof value === 'object' && value !== null
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
async function apiError(response: Response): Promise<CrawlClientApiError> {
|
|
655
|
+
let body: unknown
|
|
656
|
+
try {
|
|
657
|
+
body = await response.json()
|
|
658
|
+
} catch {
|
|
659
|
+
// A status and status text are still useful when a proxy returned non-JSON.
|
|
660
|
+
}
|
|
661
|
+
const error = isRecord(body) && isRecord(body['error']) ? body['error'] : undefined
|
|
662
|
+
const code = typeof error?.['code'] === 'string' ? error['code'] : undefined
|
|
663
|
+
const message = typeof error?.['message'] === 'string' ? error['message'] : response.statusText
|
|
664
|
+
const retryable = error?.['retryable'] === true
|
|
665
|
+
return response.status === 410
|
|
666
|
+
? new CrawlOffsetExpiredError(message, code, response.status, retryable, body)
|
|
667
|
+
: new CrawlClientApiError(message, code, response.status, retryable, body)
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function absoluteUrl(baseUrl: string, path: string): string {
|
|
671
|
+
return new URL(path, `${baseUrl.replace(/\/+$/u, '')}/`).toString()
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
interface CrawlStreamRequest {
|
|
675
|
+
readonly signal: AbortSignal
|
|
676
|
+
readonly redirectSignal: AbortSignal
|
|
677
|
+
readonly fetch: typeof globalThis.fetch
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const CRAWL_STREAM_REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308])
|
|
681
|
+
// durable-streams 0.2.6 cannot interrupt its retry timer. Retries belong to
|
|
682
|
+
// the transport below, which keeps both setup and backoff tied to the caller.
|
|
683
|
+
const CRAWL_STREAM_BACKOFF = { ...BackoffDefaults, maxRetries: 0 }
|
|
684
|
+
|
|
685
|
+
// The same ceiling the Effect client applies to `Retry-After`: a live iterator must not sleep
|
|
686
|
+
// for an hour because one response asked for it.
|
|
687
|
+
const MAX_RETRY_AFTER_MS = Duration.toMillis(MAX_RETRY_AFTER)
|
|
688
|
+
|
|
689
|
+
function retryAfterMilliseconds(error: unknown): number {
|
|
690
|
+
if (!(error instanceof FetchError)) return 0
|
|
691
|
+
const value = error.headers['retry-after']
|
|
692
|
+
if (value === undefined) return 0
|
|
693
|
+
const seconds = Number(value)
|
|
694
|
+
if (Number.isFinite(seconds) && seconds > 0) return Math.min(seconds * 1000, MAX_RETRY_AFTER_MS)
|
|
695
|
+
const date = Date.parse(value)
|
|
696
|
+
return Number.isFinite(date) ? Math.max(0, Math.min(date - Date.now(), MAX_RETRY_AFTER_MS)) : 0
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function isDeadlineReason(reason: unknown): boolean {
|
|
700
|
+
return reason instanceof Error && reason.name === 'TimeoutError'
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function crawlTimeoutMs(transport: CrawlTransportOptions): number {
|
|
704
|
+
const timeoutMs = transport.timeoutMs ?? 60_000
|
|
705
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647)
|
|
706
|
+
throw new CrawlClientError('Crawl timeoutMs must be a positive integer no greater than 2147483647.')
|
|
707
|
+
return timeoutMs
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function streamError(error: unknown, timeoutMs: number): ExpandClientError {
|
|
711
|
+
if (error instanceof ExpandClientError) return error
|
|
712
|
+
if (error instanceof FetchError) {
|
|
713
|
+
const body: unknown = error.json ?? error.text
|
|
714
|
+
const envelope = isRecord(body) && isRecord(body['error']) ? body['error'] : undefined
|
|
715
|
+
const code = typeof envelope?.['code'] === 'string' ? envelope['code'] : undefined
|
|
716
|
+
const message = typeof envelope?.['message'] === 'string' ? envelope['message'] : error.message
|
|
717
|
+
const retryable =
|
|
718
|
+
envelope?.['retryable'] === true ||
|
|
719
|
+
(envelope?.['retryable'] === undefined && (error.status === 408 || error.status === 429 || error.status >= 500))
|
|
720
|
+
return error.status === 410
|
|
721
|
+
? new CrawlOffsetExpiredError(message, code ?? 'offset_expired', error.status, retryable, body)
|
|
722
|
+
: new CrawlClientApiError(message, code, error.status, retryable, body)
|
|
723
|
+
}
|
|
724
|
+
if (error instanceof Error && error.name === 'TimeoutError')
|
|
725
|
+
return new ExpandClientTimeoutError(`Request timed out after ${timeoutMs}ms`, { timeoutMs, cause: error })
|
|
726
|
+
return new ExpandClientConnectionError(error instanceof Error ? error.message : 'Crawl request failed.', {
|
|
727
|
+
cause: error,
|
|
728
|
+
})
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function createCrawlStreamRequest(
|
|
732
|
+
transport: CrawlTransportOptions,
|
|
733
|
+
callerSignal?: AbortSignal,
|
|
734
|
+
live = false,
|
|
735
|
+
): CrawlStreamRequest {
|
|
736
|
+
const redirect = new AbortController()
|
|
737
|
+
const execute = transport.fetch ?? globalThis.fetch
|
|
738
|
+
const timeoutMs = crawlTimeoutMs(transport)
|
|
739
|
+
const maxRetries = transport.maxRetries ?? 2
|
|
740
|
+
if (!Number.isSafeInteger(maxRetries) || maxRetries < 0)
|
|
741
|
+
throw new CrawlClientError('Crawl maxRetries must be a non-negative integer.')
|
|
742
|
+
const signal = AbortSignal.any([
|
|
743
|
+
redirect.signal,
|
|
744
|
+
...(callerSignal === undefined ? [] : [callerSignal]),
|
|
745
|
+
...(live ? [] : [AbortSignal.timeout(timeoutMs)]),
|
|
746
|
+
])
|
|
747
|
+
return {
|
|
748
|
+
signal,
|
|
749
|
+
redirectSignal: redirect.signal,
|
|
750
|
+
fetch: async (input, init) => {
|
|
751
|
+
const requestSignal = init?.signal == null ? signal : AbortSignal.any([signal, init.signal])
|
|
752
|
+
let delay = BackoffDefaults.initialDelay
|
|
753
|
+
let retries = 0
|
|
754
|
+
while (true) {
|
|
755
|
+
if (requestSignal.aborted) throw abortError(requestSignal)
|
|
756
|
+
try {
|
|
757
|
+
const response = await execute(input, { ...init, signal: requestSignal, redirect: 'manual' })
|
|
758
|
+
if (CRAWL_STREAM_REDIRECT_STATUSES.has(response.status) || response.type === 'opaqueredirect') {
|
|
759
|
+
const error = new CrawlClientError(
|
|
760
|
+
'Authenticated Crawl requests cannot redirect.',
|
|
761
|
+
'redirect_not_allowed',
|
|
762
|
+
response.status || undefined,
|
|
763
|
+
false,
|
|
764
|
+
)
|
|
765
|
+
// Keep policy failures out of transport and iterator reconnect loops.
|
|
766
|
+
redirect.abort(error)
|
|
767
|
+
try {
|
|
768
|
+
await response.body?.cancel()
|
|
769
|
+
} catch {
|
|
770
|
+
// Aborting the request may already have canceled its body.
|
|
771
|
+
}
|
|
772
|
+
throw error
|
|
773
|
+
}
|
|
774
|
+
if (!response.ok) throw await FetchError.fromResponse(response, String(input))
|
|
775
|
+
return response
|
|
776
|
+
} catch (error: unknown) {
|
|
777
|
+
if (requestSignal.aborted) throw abortError(requestSignal)
|
|
778
|
+
if (!isRetryable(streamError(error, timeoutMs), timeoutMs)) throw error
|
|
779
|
+
if (!live && retries >= maxRetries) throw error
|
|
780
|
+
retries += 1
|
|
781
|
+
try {
|
|
782
|
+
await abortableDelay(Math.max(retryAfterMilliseconds(error), Math.random() * delay), requestSignal)
|
|
783
|
+
} catch (abort: unknown) {
|
|
784
|
+
// The request deadline expired while waiting to retry. The refusal being retried
|
|
785
|
+
// explains the failure; a timeout is reserved for an attempt that was in flight.
|
|
786
|
+
if (!live && isDeadlineReason(requestSignal.reason)) throw streamError(error, timeoutMs)
|
|
787
|
+
throw abort
|
|
788
|
+
}
|
|
789
|
+
delay = Math.min(delay * BackoffDefaults.multiplier, BackoffDefaults.maxDelay)
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
},
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
async function requestJson<T>(transport: CrawlTransportOptions, path: string, init: RequestInit = {}): Promise<T> {
|
|
797
|
+
const timeoutMs = crawlTimeoutMs(transport)
|
|
798
|
+
const callerSignal = transportSignal(transport, init.signal ?? undefined)
|
|
799
|
+
const request = createCrawlStreamRequest(transport, callerSignal)
|
|
800
|
+
const signal = request.signal
|
|
801
|
+
if (signal.aborted) throw abortError(signal)
|
|
802
|
+
try {
|
|
803
|
+
const response = await request.fetch(absoluteUrl(transport.baseUrl, path), {
|
|
804
|
+
...init,
|
|
805
|
+
redirect: 'error',
|
|
806
|
+
signal,
|
|
807
|
+
headers: {
|
|
808
|
+
'x-expand-api-key': transport.apiKey,
|
|
809
|
+
...(init.body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
810
|
+
...init.headers,
|
|
811
|
+
},
|
|
812
|
+
})
|
|
813
|
+
if (!response.ok) throw await apiError(response)
|
|
814
|
+
// The SDK's public response interfaces mirror the versioned HTTP contract.
|
|
815
|
+
return (await response.json()) as unknown as T
|
|
816
|
+
} catch (error: unknown) {
|
|
817
|
+
// A typed transport failure already names the cause, even when the deadline has since fired.
|
|
818
|
+
if (error instanceof ExpandClientError) throw error
|
|
819
|
+
throw streamError(signal.aborted ? abortError(signal) : error, timeoutMs)
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function abortError(signal?: AbortSignal): Error {
|
|
824
|
+
return signal?.reason instanceof Error ? signal.reason : new CrawlClientError('The Crawl operation was aborted.')
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function abortableDelay(milliseconds: number, signal?: AbortSignal): Promise<void> {
|
|
828
|
+
return new Promise((resolve, reject) => {
|
|
829
|
+
if (signal?.aborted === true) {
|
|
830
|
+
reject(abortError(signal))
|
|
831
|
+
return
|
|
832
|
+
}
|
|
833
|
+
const onAbort = () => {
|
|
834
|
+
clearTimeout(timer)
|
|
835
|
+
signal?.removeEventListener('abort', onAbort)
|
|
836
|
+
reject(abortError(signal))
|
|
837
|
+
}
|
|
838
|
+
const timer = setTimeout(() => {
|
|
839
|
+
signal?.removeEventListener('abort', onAbort)
|
|
840
|
+
resolve()
|
|
841
|
+
}, milliseconds)
|
|
842
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
843
|
+
})
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function signalIsAborted(signal?: AbortSignal): boolean {
|
|
847
|
+
return signal?.aborted === true
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const CRAWL_ITERATOR_RETURN = Symbol('crawl-iterator-return')
|
|
851
|
+
const CRAWL_ITERATOR_ABORT = Symbol('crawl-iterator-abort')
|
|
852
|
+
|
|
853
|
+
function iteratorWasCanceled(signal?: AbortSignal): boolean {
|
|
854
|
+
return signal?.reason === CRAWL_ITERATOR_RETURN || signal?.reason === CRAWL_ITERATOR_ABORT
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
export interface CrawlStreamPage<T> {
|
|
858
|
+
readonly crawlId: CrawlId
|
|
859
|
+
readonly items: readonly T[]
|
|
860
|
+
readonly nextOffset: CrawlOffset
|
|
861
|
+
readonly upToDate: boolean
|
|
862
|
+
readonly streamClosed: boolean
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
export type CrawlEventPage = CrawlStreamPage<CrawlEvent>
|
|
866
|
+
export type CrawlActivityPage = CrawlStreamPage<CrawlActivityEvent>
|
|
867
|
+
|
|
868
|
+
class CrawlIterable<T> implements AsyncIterable<T> {
|
|
869
|
+
constructor(
|
|
870
|
+
private readonly factory: (signal: AbortSignal) => AsyncGenerator<T>,
|
|
871
|
+
protected readonly externalSignal?: AbortSignal,
|
|
872
|
+
) {}
|
|
873
|
+
|
|
874
|
+
[Symbol.asyncIterator](): AsyncIterator<T> {
|
|
875
|
+
const controller = new AbortController()
|
|
876
|
+
const abort = () => controller.abort(CRAWL_ITERATOR_ABORT)
|
|
877
|
+
if (this.externalSignal?.aborted === true) abort()
|
|
878
|
+
else this.externalSignal?.addEventListener('abort', abort, { once: true })
|
|
879
|
+
const iterator = this.factory(controller.signal)
|
|
880
|
+
const cleanup = () => this.externalSignal?.removeEventListener('abort', abort)
|
|
881
|
+
return {
|
|
882
|
+
next: async () => {
|
|
883
|
+
try {
|
|
884
|
+
const result = await iterator.next()
|
|
885
|
+
if (result.done) cleanup()
|
|
886
|
+
return result
|
|
887
|
+
} catch (error: unknown) {
|
|
888
|
+
cleanup()
|
|
889
|
+
if (iteratorWasCanceled(controller.signal)) return { done: true, value: undefined }
|
|
890
|
+
throw error
|
|
891
|
+
}
|
|
892
|
+
},
|
|
893
|
+
return: async (value?: unknown) => {
|
|
894
|
+
controller.abort(CRAWL_ITERATOR_RETURN)
|
|
895
|
+
cleanup()
|
|
896
|
+
return (await iterator.return?.(value)) ?? { done: true, value: undefined }
|
|
897
|
+
},
|
|
898
|
+
throw: async (error?: unknown) => {
|
|
899
|
+
controller.abort(error)
|
|
900
|
+
cleanup()
|
|
901
|
+
if (iterator.throw === undefined) throw error
|
|
902
|
+
return iterator.throw(error)
|
|
903
|
+
},
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
async collect(options: { readonly limit: number }): Promise<T[]> {
|
|
908
|
+
if (!Number.isSafeInteger(options.limit) || options.limit < 1)
|
|
909
|
+
throw new CrawlClientError('collect limit must be positive.')
|
|
910
|
+
const events: T[] = []
|
|
911
|
+
for await (const event of this) {
|
|
912
|
+
events.push(event)
|
|
913
|
+
if (events.length >= options.limit) break
|
|
914
|
+
}
|
|
915
|
+
return events
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
export class CrawlStreamPageIterable<T>
|
|
920
|
+
extends CrawlIterable<CrawlStreamPage<T>>
|
|
921
|
+
implements PromiseLike<CrawlStreamPage<T> | undefined>
|
|
922
|
+
{
|
|
923
|
+
then<TResult1 = CrawlStreamPage<T> | undefined, TResult2 = never>(
|
|
924
|
+
onfulfilled?: ((page: CrawlStreamPage<T> | undefined) => TResult1 | PromiseLike<TResult1>) | null,
|
|
925
|
+
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
|
|
926
|
+
): PromiseLike<TResult1 | TResult2> {
|
|
927
|
+
const first = async () => {
|
|
928
|
+
const iterator = this[Symbol.asyncIterator]()
|
|
929
|
+
try {
|
|
930
|
+
const result = await iterator.next()
|
|
931
|
+
return result.done ? undefined : result.value
|
|
932
|
+
} finally {
|
|
933
|
+
await iterator.return?.()
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
return first().then(onfulfilled, onrejected)
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
export class CrawlStreamIterable<T> extends CrawlIterable<T> implements PromiseLike<CrawlStreamPage<T> | undefined> {
|
|
941
|
+
constructor(
|
|
942
|
+
factory: (signal: AbortSignal) => AsyncGenerator<T>,
|
|
943
|
+
private readonly pageFactory: (signal: AbortSignal) => AsyncGenerator<CrawlStreamPage<T>>,
|
|
944
|
+
signal?: AbortSignal,
|
|
945
|
+
) {
|
|
946
|
+
super(factory, signal)
|
|
947
|
+
}
|
|
948
|
+
pages(): CrawlStreamPageIterable<T> {
|
|
949
|
+
return new CrawlStreamPageIterable(this.pageFactory, this.externalSignal)
|
|
950
|
+
}
|
|
951
|
+
then<TResult1 = CrawlStreamPage<T> | undefined, TResult2 = never>(
|
|
952
|
+
onfulfilled?: ((page: CrawlStreamPage<T> | undefined) => TResult1 | PromiseLike<TResult1>) | null,
|
|
953
|
+
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
|
|
954
|
+
): PromiseLike<TResult1 | TResult2> {
|
|
955
|
+
return this.pages().then(onfulfilled, onrejected)
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
export class CrawlEventPageIterable extends CrawlStreamPageIterable<CrawlEvent> {}
|
|
960
|
+
export class CrawlEventIterable extends CrawlStreamIterable<CrawlEvent> {}
|
|
961
|
+
export class CrawlActivityPageIterable extends CrawlStreamPageIterable<CrawlActivityEvent> {}
|
|
962
|
+
export class CrawlActivityIterable extends CrawlStreamIterable<CrawlActivityEvent> {}
|
|
963
|
+
|
|
964
|
+
/** Which per-crawl stream a read tails. */
|
|
965
|
+
interface CrawlStreamSpec {
|
|
966
|
+
readonly path: 'events' | 'activity'
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
const PAGE_STREAM: CrawlStreamSpec = { path: 'events' }
|
|
970
|
+
const ACTIVITY_STREAM: CrawlStreamSpec = { path: 'activity' }
|
|
971
|
+
|
|
972
|
+
export class CrawlResource {
|
|
973
|
+
constructor(
|
|
974
|
+
readonly id: CrawlId,
|
|
975
|
+
private readonly transport: CrawlTransportOptions,
|
|
976
|
+
) {}
|
|
977
|
+
|
|
978
|
+
events(options: CrawlReadOptions = {}): CrawlEventIterable {
|
|
979
|
+
return new CrawlEventIterable(
|
|
980
|
+
(signal) => this.readStream<CrawlEvent>(PAGE_STREAM, { ...options, signal }),
|
|
981
|
+
(signal) => this.readStreamPages<CrawlEvent>(PAGE_STREAM, { ...options, signal }),
|
|
982
|
+
transportSignal(this.transport, options.signal),
|
|
983
|
+
)
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* The activity stream: batches of URL transitions without content, for progress and
|
|
988
|
+
* operator views. Same shape as `events`; a one-off's iteration ends on stream closure.
|
|
989
|
+
*/
|
|
990
|
+
activity(options: CrawlReadOptions = {}): CrawlActivityIterable {
|
|
991
|
+
return new CrawlActivityIterable(
|
|
992
|
+
(signal) => this.readStream<CrawlActivityEvent>(ACTIVITY_STREAM, { ...options, signal }),
|
|
993
|
+
(signal) => this.readStreamPages<CrawlActivityEvent>(ACTIVITY_STREAM, { ...options, signal }),
|
|
994
|
+
transportSignal(this.transport, options.signal),
|
|
995
|
+
)
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/** A pinned live-catalog snapshot. Resume /events from the final page's nextOffset. */
|
|
999
|
+
snapshot(options: CrawlProjectionOptions & { readonly from?: string } = {}): CrawlEventIterable {
|
|
1000
|
+
return new CrawlEventIterable(
|
|
1001
|
+
(signal) => this.readSnapshotItems({ ...options, signal }),
|
|
1002
|
+
(signal) => this.readSnapshotPages({ ...options, signal }),
|
|
1003
|
+
transportSignal(this.transport, options.signal),
|
|
1004
|
+
)
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
private async *readSnapshotItems(
|
|
1008
|
+
options: CrawlProjectionOptions & { readonly from?: string },
|
|
1009
|
+
): AsyncGenerator<CrawlEvent> {
|
|
1010
|
+
for await (const page of this.readSnapshotPages(options)) {
|
|
1011
|
+
for (const event of page.items) yield event
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
private async *readSnapshotPages(
|
|
1016
|
+
options: CrawlProjectionOptions & { readonly from?: string },
|
|
1017
|
+
): AsyncGenerator<CrawlEventPage> {
|
|
1018
|
+
let offset = options.from
|
|
1019
|
+
while (!signalIsAborted(options.signal)) {
|
|
1020
|
+
const page = await projectionJson<{
|
|
1021
|
+
readonly events: readonly CrawlEvent[]
|
|
1022
|
+
readonly nextOffset: CrawlOffset
|
|
1023
|
+
readonly upToDate: boolean
|
|
1024
|
+
}>(this.transport, projectionQuery(`/v1/crawls/${encodeURIComponent(this.id)}/snapshot`, { offset }), options)
|
|
1025
|
+
yield {
|
|
1026
|
+
crawlId: this.id,
|
|
1027
|
+
items: page.events,
|
|
1028
|
+
nextOffset: page.nextOffset,
|
|
1029
|
+
upToDate: page.upToDate,
|
|
1030
|
+
streamClosed: false,
|
|
1031
|
+
}
|
|
1032
|
+
if (page.upToDate) return
|
|
1033
|
+
if (page.nextOffset === offset) throw new CrawlClientError('Snapshot cursor did not advance.')
|
|
1034
|
+
offset = page.nextOffset
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
private async *readStreamPages<T extends { readonly offset: CrawlOffset }>(
|
|
1039
|
+
spec: CrawlStreamSpec,
|
|
1040
|
+
options: CrawlReadOptions,
|
|
1041
|
+
): AsyncGenerator<CrawlStreamPage<T>> {
|
|
1042
|
+
let offset = options.from ?? '-1'
|
|
1043
|
+
let caughtUp = false
|
|
1044
|
+
let cursor: string | undefined
|
|
1045
|
+
let recurring: boolean | undefined
|
|
1046
|
+
while (!signalIsAborted(options.signal)) {
|
|
1047
|
+
const url = new URL(absoluteUrl(this.transport.baseUrl, `/v1/crawls/${encodeURIComponent(this.id)}/${spec.path}`))
|
|
1048
|
+
if (caughtUp) url.searchParams.set('live', 'long-poll')
|
|
1049
|
+
if (cursor !== undefined) url.searchParams.set('cursor', cursor)
|
|
1050
|
+
const request = createCrawlStreamRequest(this.transport, options.signal, options.live === true)
|
|
1051
|
+
let page: JsonBatch<T>
|
|
1052
|
+
try {
|
|
1053
|
+
const response = await durableStream<T>({
|
|
1054
|
+
url: url.toString(),
|
|
1055
|
+
headers: { 'x-expand-api-key': this.transport.apiKey },
|
|
1056
|
+
offset,
|
|
1057
|
+
live: false,
|
|
1058
|
+
json: true,
|
|
1059
|
+
signal: request.signal,
|
|
1060
|
+
fetch: request.fetch,
|
|
1061
|
+
backoffOptions: CRAWL_STREAM_BACKOFF,
|
|
1062
|
+
})
|
|
1063
|
+
page = await readStreamPage(response)
|
|
1064
|
+
if (request.redirectSignal.aborted) throw abortError(request.redirectSignal)
|
|
1065
|
+
} catch (error: unknown) {
|
|
1066
|
+
if (iteratorWasCanceled(options.signal)) return
|
|
1067
|
+
if (request.redirectSignal.aborted) throw abortError(request.redirectSignal)
|
|
1068
|
+
const retryable = isRetryable(
|
|
1069
|
+
streamError(error, crawlTimeoutMs(this.transport)),
|
|
1070
|
+
crawlTimeoutMs(this.transport),
|
|
1071
|
+
)
|
|
1072
|
+
if (options.live === true && retryable) {
|
|
1073
|
+
await abortableDelay(500, options.signal)
|
|
1074
|
+
continue
|
|
1075
|
+
}
|
|
1076
|
+
throw streamError(request.signal.aborted ? abortError(request.signal) : error, crawlTimeoutMs(this.transport))
|
|
1077
|
+
}
|
|
1078
|
+
if (signalIsAborted(options.signal)) return
|
|
1079
|
+
offset = page.offset
|
|
1080
|
+
cursor = page.cursor
|
|
1081
|
+
caughtUp ||= page.upToDate
|
|
1082
|
+
yield {
|
|
1083
|
+
crawlId: this.id,
|
|
1084
|
+
items: page.items,
|
|
1085
|
+
nextOffset: offset,
|
|
1086
|
+
upToDate: page.upToDate,
|
|
1087
|
+
streamClosed: page.streamClosed,
|
|
1088
|
+
}
|
|
1089
|
+
if (page.streamClosed) return
|
|
1090
|
+
if (!page.upToDate || options.live === true) continue
|
|
1091
|
+
// Recurring reads default to catch-up. One-off completion is signaled only by EOF;
|
|
1092
|
+
// offsets are opaque and are never compared to an out-of-band watermark.
|
|
1093
|
+
if (recurring === undefined) recurring = (await this.status({ signal: options.signal })).sync !== undefined
|
|
1094
|
+
if (recurring) return
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
pages(
|
|
1099
|
+
options: CrawlProjectionOptions & {
|
|
1100
|
+
readonly limit?: number
|
|
1101
|
+
readonly cursor?: string
|
|
1102
|
+
readonly prefix?: string
|
|
1103
|
+
readonly status?: 'live' | 'removed'
|
|
1104
|
+
readonly changedSince?: string
|
|
1105
|
+
} = {},
|
|
1106
|
+
): CrawlList<CrawlCatalogPage> {
|
|
1107
|
+
return new CrawlList(
|
|
1108
|
+
async (cursor) => {
|
|
1109
|
+
const result = await projectionJson<{ readonly pages: CrawlCatalogPage[]; readonly nextCursor?: string }>(
|
|
1110
|
+
this.transport,
|
|
1111
|
+
projectionQuery(`/v1/crawls/${encodeURIComponent(this.id)}/pages`, {
|
|
1112
|
+
cursor,
|
|
1113
|
+
limit: options.limit,
|
|
1114
|
+
prefix: options.prefix,
|
|
1115
|
+
status: options.status,
|
|
1116
|
+
changedSince: options.changedSince,
|
|
1117
|
+
}),
|
|
1118
|
+
options,
|
|
1119
|
+
)
|
|
1120
|
+
return { items: result.pages, nextCursor: result.nextCursor }
|
|
1121
|
+
},
|
|
1122
|
+
options.cursor,
|
|
1123
|
+
transportSignal(this.transport, options.signal),
|
|
1124
|
+
)
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* Every discovered URL in any admission state, cursor-paginated and filterable by state,
|
|
1129
|
+
* source and a URL substring (`search`, matched literally and case-insensitively across the
|
|
1130
|
+
* whole crawl). The first page's `activityOffset` is where to start an activity tail that
|
|
1131
|
+
* keeps the snapshot current: apply an event to a URL only when `event.offset > url.activitySeq`.
|
|
1132
|
+
*/
|
|
1133
|
+
urls(
|
|
1134
|
+
options: CrawlProjectionOptions & {
|
|
1135
|
+
readonly limit?: number
|
|
1136
|
+
readonly cursor?: string
|
|
1137
|
+
readonly state?: CrawlUrl['state']
|
|
1138
|
+
readonly source?: string
|
|
1139
|
+
readonly search?: string
|
|
1140
|
+
} = {},
|
|
1141
|
+
): CrawlList<CrawlUrl> {
|
|
1142
|
+
return new CrawlList(
|
|
1143
|
+
async (cursor) => {
|
|
1144
|
+
const result = await projectionJson<CrawlUrlListPage>(
|
|
1145
|
+
this.transport,
|
|
1146
|
+
projectionQuery(`/v1/crawls/${encodeURIComponent(this.id)}/urls`, {
|
|
1147
|
+
cursor,
|
|
1148
|
+
limit: options.limit,
|
|
1149
|
+
state: options.state,
|
|
1150
|
+
source: options.source,
|
|
1151
|
+
search: options.search,
|
|
1152
|
+
}),
|
|
1153
|
+
options,
|
|
1154
|
+
)
|
|
1155
|
+
return { items: result.urls, nextCursor: result.nextCursor }
|
|
1156
|
+
},
|
|
1157
|
+
options.cursor,
|
|
1158
|
+
transportSignal(this.transport, options.signal),
|
|
1159
|
+
)
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/** One page of the discovered-URLs view with its counts and activity offset, for callers that need the envelope. */
|
|
1163
|
+
urlsPage(
|
|
1164
|
+
options: CrawlProjectionOptions & {
|
|
1165
|
+
readonly limit?: number
|
|
1166
|
+
readonly cursor?: string
|
|
1167
|
+
readonly state?: CrawlUrl['state']
|
|
1168
|
+
readonly source?: string
|
|
1169
|
+
readonly search?: string
|
|
1170
|
+
} = {},
|
|
1171
|
+
): Promise<CrawlUrlListPage> {
|
|
1172
|
+
return projectionJson<CrawlUrlListPage>(
|
|
1173
|
+
this.transport,
|
|
1174
|
+
projectionQuery(`/v1/crawls/${encodeURIComponent(this.id)}/urls`, {
|
|
1175
|
+
cursor: options.cursor,
|
|
1176
|
+
limit: options.limit,
|
|
1177
|
+
state: options.state,
|
|
1178
|
+
source: options.source,
|
|
1179
|
+
search: options.search,
|
|
1180
|
+
}),
|
|
1181
|
+
options,
|
|
1182
|
+
)
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
page(id: string, options: CrawlProjectionOptions = {}): Promise<CrawlCurrentPage> {
|
|
1186
|
+
return projectionJson(
|
|
1187
|
+
this.transport,
|
|
1188
|
+
`/v1/crawls/${encodeURIComponent(this.id)}/pages/${encodeURIComponent(id)}`,
|
|
1189
|
+
options,
|
|
1190
|
+
)
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
pageByUrl(url: string, options: CrawlProjectionOptions = {}): Promise<CrawlCurrentPage> {
|
|
1194
|
+
return projectionJson(
|
|
1195
|
+
this.transport,
|
|
1196
|
+
projectionQuery(`/v1/crawls/${encodeURIComponent(this.id)}/pages/by-url`, { url }),
|
|
1197
|
+
options,
|
|
1198
|
+
)
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
private async *readStream<T extends { readonly offset: CrawlOffset }>(
|
|
1202
|
+
spec: CrawlStreamSpec,
|
|
1203
|
+
options: CrawlReadOptions,
|
|
1204
|
+
): AsyncGenerator<T> {
|
|
1205
|
+
for await (const page of this.readStreamPages<T>(spec, options)) {
|
|
1206
|
+
for (const event of page.items) {
|
|
1207
|
+
if (signalIsAborted(options.signal)) return
|
|
1208
|
+
yield event
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
status(options: { readonly signal?: AbortSignal } = {}): Promise<CrawlStatus> {
|
|
1214
|
+
return requestJson<CrawlStatus>(this.transport, `/v1/crawls/${encodeURIComponent(this.id)}`, {
|
|
1215
|
+
signal: options.signal,
|
|
1216
|
+
})
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
async done(
|
|
1220
|
+
options: { readonly signal?: AbortSignal; readonly pollIntervalMs?: number } = {},
|
|
1221
|
+
): Promise<CrawlDoneResult> {
|
|
1222
|
+
if (
|
|
1223
|
+
options.pollIntervalMs !== undefined &&
|
|
1224
|
+
(!Number.isFinite(options.pollIntervalMs) || options.pollIntervalMs > 2_147_483_647)
|
|
1225
|
+
)
|
|
1226
|
+
throw new CrawlClientError('Crawl pollIntervalMs must be finite and no greater than 2147483647.')
|
|
1227
|
+
const interval = Math.max(50, options.pollIntervalMs ?? 500)
|
|
1228
|
+
const signal = transportSignal(this.transport, options.signal)
|
|
1229
|
+
while (!signalIsAborted(signal)) {
|
|
1230
|
+
const status = await this.status({ signal })
|
|
1231
|
+
if (signalIsAborted(signal)) break
|
|
1232
|
+
if (
|
|
1233
|
+
status.state === 'done' ||
|
|
1234
|
+
status.state === 'failed' ||
|
|
1235
|
+
status.state === 'canceled' ||
|
|
1236
|
+
(status.sync !== undefined && status.progress.queued === 0 && status.progress.inFlight === 0)
|
|
1237
|
+
) {
|
|
1238
|
+
return { lastOffset: status.offset, status }
|
|
1239
|
+
}
|
|
1240
|
+
await abortableDelay(interval, signal)
|
|
1241
|
+
}
|
|
1242
|
+
const reason: unknown = signal?.reason
|
|
1243
|
+
throw reason instanceof Error ? reason : new CrawlClientError('Crawl.done was aborted.')
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
update(patch: {
|
|
1247
|
+
readonly sync?: CrawlSyncPolicy | null
|
|
1248
|
+
readonly changePolicy?: CrawlChangePolicy
|
|
1249
|
+
readonly maxPages?: number
|
|
1250
|
+
/** `null` clears the name. A name can be changed even on a closed or archived crawl. */
|
|
1251
|
+
readonly name?: string | null
|
|
1252
|
+
}): Promise<CrawlStatus> {
|
|
1253
|
+
return requestJson<CrawlStatus>(this.transport, `/v1/crawls/${encodeURIComponent(this.id)}`, {
|
|
1254
|
+
method: 'PATCH',
|
|
1255
|
+
body: JSON.stringify(patch),
|
|
1256
|
+
})
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
cancel(): Promise<CrawlStatus> {
|
|
1260
|
+
return requestJson<CrawlStatus>(this.transport, `/v1/crawls/${encodeURIComponent(this.id)}/cancel`, {
|
|
1261
|
+
method: 'POST',
|
|
1262
|
+
})
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/** Cancels the crawl if it is live and hides it from `list()`. It is deleted 90 days later unless unarchived. */
|
|
1266
|
+
archive(): Promise<CrawlStatus> {
|
|
1267
|
+
return requestJson<CrawlStatus>(this.transport, `/v1/crawls/${encodeURIComponent(this.id)}/archive`, {
|
|
1268
|
+
method: 'POST',
|
|
1269
|
+
})
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/** Returns an archived crawl to `list()`, still canceled, and keeps it for at least 90 more days. */
|
|
1273
|
+
unarchive(): Promise<CrawlStatus> {
|
|
1274
|
+
return requestJson<CrawlStatus>(this.transport, `/v1/crawls/${encodeURIComponent(this.id)}/unarchive`, {
|
|
1275
|
+
method: 'POST',
|
|
1276
|
+
})
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
run(options: CrawlRunOptions): Promise<CrawlStatus> {
|
|
1280
|
+
const idempotencyKey = requireIdempotencyKey(options?.idempotencyKey, 'Crawl.run')
|
|
1281
|
+
return requestJson<CrawlStatus>(
|
|
1282
|
+
this.transport,
|
|
1283
|
+
`/v1/crawls/${encodeURIComponent(this.id)}/runs${options.replayOnly ? '/replay' : ''}`,
|
|
1284
|
+
{
|
|
1285
|
+
method: 'POST',
|
|
1286
|
+
headers: {
|
|
1287
|
+
'x-idempotency-key': idempotencyKey,
|
|
1288
|
+
},
|
|
1289
|
+
},
|
|
1290
|
+
)
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
export class MergedCrawlResource {
|
|
1295
|
+
constructor(private readonly crawls: readonly CrawlResource[]) {}
|
|
1296
|
+
|
|
1297
|
+
events(options: MergedCrawlReadOptions = {}): CrawlEventIterable {
|
|
1298
|
+
if (typeof options.from === 'string')
|
|
1299
|
+
throw new CrawlClientError('Merged Crawl reads require an offset map keyed by Crawl id.')
|
|
1300
|
+
return new CrawlEventIterable(
|
|
1301
|
+
(signal) => this.readItems({ ...options, signal }, (events) => events),
|
|
1302
|
+
(signal) => this.readItems({ ...options, signal }, (events) => events.pages()),
|
|
1303
|
+
options.signal,
|
|
1304
|
+
)
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
private async *readItems<T>(
|
|
1308
|
+
options: MergedCrawlReadOptions,
|
|
1309
|
+
select: (events: CrawlEventIterable) => AsyncIterable<T>,
|
|
1310
|
+
): AsyncGenerator<T> {
|
|
1311
|
+
if (signalIsAborted(options.signal)) return
|
|
1312
|
+
const controllers = this.crawls.map(() => new AbortController())
|
|
1313
|
+
const abortChildren = () => {
|
|
1314
|
+
for (const controller of controllers) controller.abort(options.signal?.reason)
|
|
1315
|
+
}
|
|
1316
|
+
options.signal?.addEventListener('abort', abortChildren, { once: true })
|
|
1317
|
+
const iterators = this.crawls.map((crawl, index) =>
|
|
1318
|
+
select(crawl.events({ from: options.from?.[crawl.id], live: options.live, signal: controllers[index]?.signal }))[
|
|
1319
|
+
Symbol.asyncIterator
|
|
1320
|
+
](),
|
|
1321
|
+
)
|
|
1322
|
+
const pending = new Map<number, Promise<{ readonly index: number; readonly result: IteratorResult<T> }>>()
|
|
1323
|
+
for (const [index, iterator] of iterators.entries()) {
|
|
1324
|
+
pending.set(
|
|
1325
|
+
index,
|
|
1326
|
+
iterator.next().then((result) => ({ index, result })),
|
|
1327
|
+
)
|
|
1328
|
+
}
|
|
1329
|
+
try {
|
|
1330
|
+
while (pending.size > 0) {
|
|
1331
|
+
const next = await Promise.race(pending.values())
|
|
1332
|
+
if (next.result.done) {
|
|
1333
|
+
pending.delete(next.index)
|
|
1334
|
+
continue
|
|
1335
|
+
}
|
|
1336
|
+
yield next.result.value
|
|
1337
|
+
const iterator = iterators[next.index]
|
|
1338
|
+
if (iterator !== undefined)
|
|
1339
|
+
pending.set(
|
|
1340
|
+
next.index,
|
|
1341
|
+
iterator.next().then((result) => ({ index: next.index, result })),
|
|
1342
|
+
)
|
|
1343
|
+
}
|
|
1344
|
+
} finally {
|
|
1345
|
+
abortChildren()
|
|
1346
|
+
options.signal?.removeEventListener('abort', abortChildren)
|
|
1347
|
+
await Promise.allSettled(iterators.map((iterator) => iterator.return?.(undefined)))
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
/** Create, list and control crawls. */
|
|
1353
|
+
export interface CrawlsClient {
|
|
1354
|
+
(id: string): CrawlResource
|
|
1355
|
+
create(request: CrawlRequest, options: CrawlCreateOptions): Promise<CrawlResource[]>
|
|
1356
|
+
/** The organization's crawls, newest first, each with its current status. */
|
|
1357
|
+
list(options?: CrawlListOptions): Promise<CrawlListResponse>
|
|
1358
|
+
merge(crawls: readonly CrawlResource[]): MergedCrawlResource
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
export function makeCrawlsClient(options: CrawlTransportOptions): CrawlsClient {
|
|
1362
|
+
function byId(id: string): CrawlResource {
|
|
1363
|
+
if (id.length === 0) throw new CrawlClientError('Crawl id must not be empty.')
|
|
1364
|
+
return new CrawlResource(id, options)
|
|
1365
|
+
}
|
|
1366
|
+
return Object.assign(byId, {
|
|
1367
|
+
async create(request: CrawlRequest, createOptions: CrawlCreateOptions): Promise<CrawlResource[]> {
|
|
1368
|
+
const idempotencyKey = requireIdempotencyKey(createOptions?.idempotencyKey, 'Crawls.create')
|
|
1369
|
+
const created = await requestJson<CreatedCrawlResponse[]>(options, '/v1/crawls', {
|
|
1370
|
+
method: 'POST',
|
|
1371
|
+
headers: { 'x-idempotency-key': idempotencyKey },
|
|
1372
|
+
body: JSON.stringify(request),
|
|
1373
|
+
})
|
|
1374
|
+
return created.map(({ id }) => new CrawlResource(id, options))
|
|
1375
|
+
},
|
|
1376
|
+
list(listOptions: CrawlListOptions = {}): Promise<CrawlListResponse> {
|
|
1377
|
+
const params = new URLSearchParams()
|
|
1378
|
+
if (listOptions.limit !== undefined) params.set('limit', String(listOptions.limit))
|
|
1379
|
+
if (listOptions.archived !== undefined) params.set('archived', String(listOptions.archived))
|
|
1380
|
+
const query = params.size === 0 ? '' : `?${params.toString()}`
|
|
1381
|
+
return requestJson<CrawlListResponse>(options, `/v1/crawls${query}`, { signal: listOptions.signal })
|
|
1382
|
+
},
|
|
1383
|
+
merge(crawls: readonly CrawlResource[]): MergedCrawlResource {
|
|
1384
|
+
return new MergedCrawlResource(crawls)
|
|
1385
|
+
},
|
|
1386
|
+
})
|
|
1387
|
+
}
|