@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
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// Appendix omission markers as they appear in Fetch Markdown.
|
|
2
|
+
//
|
|
3
|
+
// Mirrors `packages/core/src/Fetch/AppendixMarkers.ts` (the SDK cannot depend
|
|
4
|
+
// on core). Keep the grammar in sync with the Rust emitter in
|
|
5
|
+
// `native/expand-snapshot/src/markdown/pruner/appendix_markers.rs`.
|
|
6
|
+
//
|
|
7
|
+
// A marker is placed wherever block content renders, so it can arrive with a
|
|
8
|
+
// blockquote or list-item prefix (`> [appendix omitted: …]`). Marker-shaped
|
|
9
|
+
// lines inside fenced code are page text, not markers, and are left alone.
|
|
10
|
+
//
|
|
11
|
+
// [appendix omitted: 14 links · ~320 tokens] {612}
|
|
12
|
+
// [appendix omitted: navigation — Home, News, Sport, +11 more · 14 links · ~320 tokens] {612}
|
|
13
|
+
// [appendix omitted: 7 more blocks · 41 links · ~2100 tokens] (trailing fold; id is the first folded block when bound)
|
|
14
|
+
|
|
15
|
+
import { Schema } from 'effect'
|
|
16
|
+
|
|
17
|
+
export const APPENDIX_MARKER_PREFIX = '[appendix omitted: '
|
|
18
|
+
|
|
19
|
+
export const AppendixMarkerLevel = Schema.Literal('off', 'compact', 'detailed')
|
|
20
|
+
export type AppendixMarkerLevel = typeof AppendixMarkerLevel.Type
|
|
21
|
+
|
|
22
|
+
const MARKER_LINE =
|
|
23
|
+
/^\[appendix omitted: (?:(?<more>\d+) more blocks? · |(?<label>[a-z]+)(?: — (?<preview>.+?))? · )?(?<links>\d+) links? · ~(?<tokens>\d+) tokens\](?: \{(?<id>\d+)\})?$/u
|
|
24
|
+
|
|
25
|
+
/** One blockquote or list-item prefix, as Markdown renders it. */
|
|
26
|
+
const CONTAINER_PREFIX = /^(?:> ?|[*+-][ \t]|\d+[.)][ \t])/u
|
|
27
|
+
|
|
28
|
+
const FENCE_LINE = /^(`{3,}|~{3,})/u
|
|
29
|
+
|
|
30
|
+
/** A list bullet or ordered-list number introducing an item. */
|
|
31
|
+
const LIST_MARKER = /^(?:[*+-]|\d+[.)])(?=[ \t]|$)/u
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A marker rendered inside a blockquote or a list item carries that container's
|
|
35
|
+
* prefix (`> `, `- `, `1. `, and nestings of those). The Rust emitter places
|
|
36
|
+
* markers wherever block content renders, so consumers have to see through
|
|
37
|
+
* those prefixes or the marker reads as page content.
|
|
38
|
+
*/
|
|
39
|
+
function markerBody(line: string): string {
|
|
40
|
+
let rest = line.trimStart()
|
|
41
|
+
while (!rest.startsWith(APPENDIX_MARKER_PREFIX)) {
|
|
42
|
+
const prefix = CONTAINER_PREFIX.exec(rest)
|
|
43
|
+
if (!prefix) return rest
|
|
44
|
+
rest = rest.slice(prefix[0].length).trimStart()
|
|
45
|
+
}
|
|
46
|
+
return rest
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface OpenFence {
|
|
50
|
+
/** Blockquote nesting the fence opened at. */
|
|
51
|
+
readonly depth: number
|
|
52
|
+
/**
|
|
53
|
+
* Content column of the list item the fence opened inside. A line that
|
|
54
|
+
* dedents out of that item ends the block, closed or not — an unclosed fence
|
|
55
|
+
* must not outlive its container.
|
|
56
|
+
*/
|
|
57
|
+
readonly itemContentColumn: number
|
|
58
|
+
/**
|
|
59
|
+
* Column the fence started at. A closing delimiter may be indented up to
|
|
60
|
+
* three columns further.
|
|
61
|
+
*/
|
|
62
|
+
readonly indent: number
|
|
63
|
+
readonly fenceChar: string
|
|
64
|
+
readonly run: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface ContainerPrefix {
|
|
68
|
+
readonly depth: number
|
|
69
|
+
/** Spaces before any container marker on this line. */
|
|
70
|
+
readonly rawIndent: number
|
|
71
|
+
/**
|
|
72
|
+
* Column just after the last container marker, or `undefined` when the line
|
|
73
|
+
* opens no container. Indentation past it is indented code *inside* that
|
|
74
|
+
* container.
|
|
75
|
+
*/
|
|
76
|
+
readonly afterMarkers: number | undefined
|
|
77
|
+
/**
|
|
78
|
+
* Column the outermost list marker starts at, or `Number.MAX_SAFE_INTEGER`
|
|
79
|
+
* when the line opens no item. A continuation line may dedent to here
|
|
80
|
+
* without leaving the item.
|
|
81
|
+
*/
|
|
82
|
+
readonly markerColumn: number
|
|
83
|
+
/**
|
|
84
|
+
* Every item this line opens, outermost first, as the quote depth in force
|
|
85
|
+
* where its marker stood and the content column it starts. `10. * nested`
|
|
86
|
+
* opens two; `10. > quote` opens one at depth zero even though the line ends
|
|
87
|
+
* inside a quote.
|
|
88
|
+
*/
|
|
89
|
+
readonly openedItems: { depth: number; column: number }[]
|
|
90
|
+
/** Columns consumed by indentation and container markers together. */
|
|
91
|
+
readonly column: number
|
|
92
|
+
readonly sawListMarker: boolean
|
|
93
|
+
readonly rest: string
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The container markers a line opens with, and where they leave it.
|
|
98
|
+
*
|
|
99
|
+
* Blockquotes and list items nest in either order (`- > ```rust`), so the two
|
|
100
|
+
* are stripped in turn rather than one after the other. A `>` takes one
|
|
101
|
+
* optional space with it, which is the quote marker rather than indentation.
|
|
102
|
+
* A closing delimiter is read with `allowListMarkers` false: a bullet on a line
|
|
103
|
+
* *inside* a block is code, and reading it as container syntax ends the block.
|
|
104
|
+
*/
|
|
105
|
+
function containerPrefix(line: string, allowListMarkers: boolean): ContainerPrefix {
|
|
106
|
+
const rawIndent = leadingIndent(line, 0).columns
|
|
107
|
+
let rest = line
|
|
108
|
+
let depth = 0
|
|
109
|
+
let column = 0
|
|
110
|
+
let markerColumn = Number.MAX_SAFE_INTEGER
|
|
111
|
+
let afterMarkers: number | undefined
|
|
112
|
+
const openedItems: { depth: number; column: number }[] = []
|
|
113
|
+
let sawListMarker = false
|
|
114
|
+
for (;;) {
|
|
115
|
+
const { bytes, columns } = leadingIndent(rest, column)
|
|
116
|
+
rest = rest.slice(bytes)
|
|
117
|
+
column += columns
|
|
118
|
+
if (rest.startsWith('>')) {
|
|
119
|
+
depth += 1
|
|
120
|
+
const after = rest.slice(1).startsWith(' ') ? rest.slice(2) : rest.slice(1)
|
|
121
|
+
column += rest.length - after.length
|
|
122
|
+
rest = after
|
|
123
|
+
afterMarkers = column
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
const { width, content } = allowListMarkers ? listMarkerWidth(rest, column) : { width: 0, content: rest }
|
|
127
|
+
if (width > 0) {
|
|
128
|
+
sawListMarker = true
|
|
129
|
+
markerColumn = Math.min(markerColumn, column)
|
|
130
|
+
column += width
|
|
131
|
+
rest = content
|
|
132
|
+
afterMarkers = column
|
|
133
|
+
openedItems.push({ depth, column })
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
return { depth, rawIndent, afterMarkers, markerColumn, openedItems, column, sawListMarker, rest }
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The column a line begins at for container purposes: its own list marker when
|
|
142
|
+
* it opens an item, otherwise its content.
|
|
143
|
+
*/
|
|
144
|
+
function startsAt(prefix: ContainerPrefix): number {
|
|
145
|
+
return prefix.sawListMarker ? prefix.markerColumn : prefix.column
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Whether a line's content is an indented code block — four columns past
|
|
150
|
+
* whatever contains it. That is either the enclosing list item, for the
|
|
151
|
+
* indentation before this line's own markers, or the innermost marker this line
|
|
152
|
+
* opens, for the indentation after them.
|
|
153
|
+
*/
|
|
154
|
+
function isIndentedCode(prefix: ContainerPrefix, contentColumn: number): boolean {
|
|
155
|
+
const inside = Math.max(contentColumn, prefix.afterMarkers ?? 0)
|
|
156
|
+
return prefix.rawIndent >= contentColumn + 4 || prefix.column >= inside + 4
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Bytes and columns taken by leading whitespace. A tab advances to the next
|
|
161
|
+
* four-column stop, as CommonMark counts it — code bodies are full of them, and
|
|
162
|
+
* reading one as no indentation puts a line outside its own block.
|
|
163
|
+
*/
|
|
164
|
+
function leadingIndent(rest: string, startColumn: number): { bytes: number; columns: number } {
|
|
165
|
+
let bytes = 0
|
|
166
|
+
let column = startColumn
|
|
167
|
+
for (const character of rest) {
|
|
168
|
+
if (character === ' ') column += 1
|
|
169
|
+
// A tab stop is measured from where the line already is, not from the
|
|
170
|
+
// start of the run.
|
|
171
|
+
else if (character === '\t') column += 4 - (column % 4)
|
|
172
|
+
else break
|
|
173
|
+
bytes += 1
|
|
174
|
+
}
|
|
175
|
+
return { bytes, columns: column - startColumn }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A run of three or more backticks or tildes at the start of `content`. */
|
|
179
|
+
function fenceRun(content: string): { fenceChar: string; run: number } | undefined {
|
|
180
|
+
const match = FENCE_LINE.exec(content)?.[1]
|
|
181
|
+
return match === undefined ? undefined : { fenceChar: match[0], run: match.length }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Columns taken by a leading list marker, and the content after it. */
|
|
185
|
+
function listMarkerWidth(rest: string, startColumn: number): { width: number; content: string } {
|
|
186
|
+
const marker = LIST_MARKER.exec(rest)?.[0]
|
|
187
|
+
if (marker === undefined) return { width: 0, content: rest }
|
|
188
|
+
const afterMarker = rest.slice(marker.length)
|
|
189
|
+
const padding = leadingIndent(afterMarker, startColumn + marker.length)
|
|
190
|
+
// More than four columns of padding is an indented code block inside the
|
|
191
|
+
// item, not a wider marker: the item's content starts one column past it.
|
|
192
|
+
if (padding.columns > 4) return { width: marker.length, content: afterMarker }
|
|
193
|
+
return { width: marker.length + padding.columns, content: rest.slice(marker.length + padding.bytes) }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The fence a line opens, if any. The caller has already established that the
|
|
198
|
+
* line is not indented code.
|
|
199
|
+
*/
|
|
200
|
+
function opensFence(prefix: ContainerPrefix, itemContentColumn: number): OpenFence | undefined {
|
|
201
|
+
const run = fenceRun(prefix.rest)
|
|
202
|
+
if (run === undefined) return undefined
|
|
203
|
+
// A backtick fence's info string may not contain a backtick, so ``` `` ``` is
|
|
204
|
+
// a paragraph with inline code in it, not a block that swallows what follows.
|
|
205
|
+
if (run.fenceChar === '`' && prefix.rest.slice(run.run).includes('`')) return undefined
|
|
206
|
+
return { depth: prefix.depth, indent: prefix.column, itemContentColumn, ...run }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* A fence closes on a run of the same character, at least as long as the open,
|
|
211
|
+
* at the same blockquote depth and no more than three columns further in, with
|
|
212
|
+
* nothing but whitespace after it. Everything else inside the block — an info
|
|
213
|
+
* string, a `>`, a list bullet — is code.
|
|
214
|
+
*/
|
|
215
|
+
function closesFence(line: string, open: OpenFence): boolean {
|
|
216
|
+
const prefix = containerPrefix(line, false)
|
|
217
|
+
if (prefix.depth !== open.depth || prefix.column > open.indent + 3) return false
|
|
218
|
+
const run = fenceRun(prefix.rest)
|
|
219
|
+
if (run === undefined || run.fenceChar !== open.fenceChar || run.run < open.run) return false
|
|
220
|
+
return prefix.rest.slice(run.run).trim() === ''
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Whether a line has stepped out of the list item a fence opened inside, which
|
|
225
|
+
* ends the block whether or not a closing delimiter ever arrived.
|
|
226
|
+
*/
|
|
227
|
+
function leavesFenceContainer(line: string, open: OpenFence): boolean {
|
|
228
|
+
if (open.itemContentColumn === 0 && open.depth === 0) return false
|
|
229
|
+
const prefix = containerPrefix(line, true)
|
|
230
|
+
if (prefix.rest.trim() === '' && !prefix.sawListMarker) return false
|
|
231
|
+
return prefix.depth < open.depth || startsAt(prefix) < open.itemContentColumn
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Walk a body's lines, reporting each with the marker it carries, if any.
|
|
236
|
+
*
|
|
237
|
+
* The list items a line sits inside are tracked as a stack of their content
|
|
238
|
+
* columns, because that is the whole difference between four spaces meaning an
|
|
239
|
+
* indented code block and four spaces meaning ordinary item content — and a
|
|
240
|
+
* delimiter that is a fence in one is code in the other.
|
|
241
|
+
*/
|
|
242
|
+
function eachLine(
|
|
243
|
+
markdown: string,
|
|
244
|
+
visit: (line: string, marker: AppendixMarker | undefined, fenced: boolean) => void,
|
|
245
|
+
): void {
|
|
246
|
+
// Blockquote depth and content column of each open item, outermost first. The
|
|
247
|
+
// depth matters because an item inside a quote ends when the quote does,
|
|
248
|
+
// however its columns compare.
|
|
249
|
+
const openItems: { depth: number; column: number }[] = []
|
|
250
|
+
let fence: OpenFence | undefined
|
|
251
|
+
for (const raw of markdown.split('\n')) {
|
|
252
|
+
// `lines()` in the Rust twin drops the carriage return; a body captured
|
|
253
|
+
// with CRLF must classify the same either way.
|
|
254
|
+
const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw
|
|
255
|
+
if (fence !== undefined) {
|
|
256
|
+
// A line that steps out of the fence's list item is not part of the
|
|
257
|
+
// block — the block ended above it — so it still has to be read.
|
|
258
|
+
if (leavesFenceContainer(line, fence)) {
|
|
259
|
+
fence = undefined
|
|
260
|
+
} else {
|
|
261
|
+
const closing = closesFence(line, fence)
|
|
262
|
+
if (closing) fence = undefined
|
|
263
|
+
// The delimiters belong to the document; only the body between them
|
|
264
|
+
// is code a consumer may drop.
|
|
265
|
+
visit(raw, undefined, !closing)
|
|
266
|
+
continue
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const prefix = containerPrefix(line, true)
|
|
270
|
+
// A blank line neither opens nor closes a container, and the items it sits
|
|
271
|
+
// inside continue after it. An empty list item (`10.` with its content on a
|
|
272
|
+
// later line) looks blank once its marker is stripped, and is not.
|
|
273
|
+
if (prefix.rest.trim() === '' && !prefix.sawListMarker) {
|
|
274
|
+
visit(raw, undefined, false)
|
|
275
|
+
continue
|
|
276
|
+
}
|
|
277
|
+
while (openItems.length > 0) {
|
|
278
|
+
const innermost = openItems[openItems.length - 1]
|
|
279
|
+
if (innermost.depth <= prefix.depth && startsAt(prefix) >= innermost.column) break
|
|
280
|
+
openItems.pop()
|
|
281
|
+
}
|
|
282
|
+
const contentColumn = openItems[openItems.length - 1]?.column ?? 0
|
|
283
|
+
// An indented code block is not a list item, not a fence, and not a
|
|
284
|
+
// marker — it is code, whatever it looks like.
|
|
285
|
+
if (isIndentedCode(prefix, contentColumn)) {
|
|
286
|
+
visit(raw, undefined, false)
|
|
287
|
+
continue
|
|
288
|
+
}
|
|
289
|
+
for (const opened of prefix.openedItems) openItems.push(opened)
|
|
290
|
+
const opening = opensFence(prefix, openItems[openItems.length - 1]?.column ?? 0)
|
|
291
|
+
if (opening !== undefined) {
|
|
292
|
+
fence = opening
|
|
293
|
+
visit(raw, undefined, false)
|
|
294
|
+
continue
|
|
295
|
+
}
|
|
296
|
+
visit(raw, parseAppendixMarkerLine(line), false)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* A body with the *contents* of fenced code removed, delimiters kept. Evidence
|
|
302
|
+
* ids inside a code example are page text, not citations — but a code block's
|
|
303
|
+
* own id rides on its opening fence, so the delimiters have to stay.
|
|
304
|
+
*/
|
|
305
|
+
export function withoutFencedCode(markdown: string): string {
|
|
306
|
+
const kept: string[] = []
|
|
307
|
+
eachLine(markdown, (line, _marker, fenced) => {
|
|
308
|
+
if (!fenced) kept.push(line)
|
|
309
|
+
})
|
|
310
|
+
return kept.join('\n')
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export interface AppendixMarker {
|
|
314
|
+
/** Evidence id of the first omitted block; on the trailing fold marker, the first block of the first folded run. */
|
|
315
|
+
readonly evidenceId?: number
|
|
316
|
+
readonly links: number
|
|
317
|
+
readonly approxTokens: number
|
|
318
|
+
readonly label?: string
|
|
319
|
+
readonly preview?: string
|
|
320
|
+
/** On the trailing fold marker: how many further omitted runs it stands for. */
|
|
321
|
+
readonly moreBlocks?: number
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function isAppendixMarkerLine(line: string): boolean {
|
|
325
|
+
const body = markerBody(line)
|
|
326
|
+
return body.startsWith(APPENDIX_MARKER_PREFIX) && MARKER_LINE.test(body.trimEnd())
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function parseAppendixMarkerLine(line: string): AppendixMarker | undefined {
|
|
330
|
+
const groups = MARKER_LINE.exec(markerBody(line).trimEnd())?.groups
|
|
331
|
+
if (!groups) return undefined
|
|
332
|
+
const links = Number(groups.links)
|
|
333
|
+
const approxTokens = Number(groups.tokens)
|
|
334
|
+
if (!Number.isSafeInteger(links) || !Number.isSafeInteger(approxTokens)) return undefined
|
|
335
|
+
const evidenceId = groups.id === undefined ? undefined : Number(groups.id)
|
|
336
|
+
const moreBlocks = groups.more === undefined ? undefined : Number(groups.more)
|
|
337
|
+
return {
|
|
338
|
+
evidenceId: evidenceId !== undefined && Number.isSafeInteger(evidenceId) ? evidenceId : undefined,
|
|
339
|
+
links,
|
|
340
|
+
approxTokens,
|
|
341
|
+
label: groups.label,
|
|
342
|
+
preview: groups.preview,
|
|
343
|
+
moreBlocks: moreBlocks !== undefined && Number.isSafeInteger(moreBlocks) ? moreBlocks : undefined,
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Remove marker paragraphs from a Markdown body. Markers are synthetic: they
|
|
349
|
+
* are never search snippets, never citable, and never scored by quality evals.
|
|
350
|
+
*/
|
|
351
|
+
export function stripAppendixMarkers(markdown: string): string {
|
|
352
|
+
if (!markdown.includes(APPENDIX_MARKER_PREFIX)) return markdown
|
|
353
|
+
const kept: string[] = []
|
|
354
|
+
eachLine(markdown, (line, marker) => {
|
|
355
|
+
if (!marker) kept.push(line)
|
|
356
|
+
})
|
|
357
|
+
return kept
|
|
358
|
+
.join('\n')
|
|
359
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
360
|
+
.replace(/\n+$/u, '\n')
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Every marker in a Markdown body, in document order. */
|
|
364
|
+
export function parseAppendixMarkers(markdown: string): AppendixMarker[] {
|
|
365
|
+
const markers: AppendixMarker[] = []
|
|
366
|
+
eachLine(markdown, (_line, marker) => {
|
|
367
|
+
if (marker) markers.push(marker)
|
|
368
|
+
})
|
|
369
|
+
return markers
|
|
370
|
+
}
|
package/src/Client.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
+
import { Undici } from '@effect/platform-node'
|
|
1
2
|
import { Effect, Either, Exit, Layer } from 'effect'
|
|
3
|
+
import {
|
|
4
|
+
type CrawlProjectionOptions,
|
|
5
|
+
type CrawlsClient,
|
|
6
|
+
makeCrawlProjectionsClient,
|
|
7
|
+
makeCrawlsClient,
|
|
8
|
+
} from './Crawls.js'
|
|
9
|
+
import { ExpandClientError } from './Error.js'
|
|
2
10
|
import { InternalClient } from './Internal.js'
|
|
3
11
|
import {
|
|
4
12
|
type BatchedParams,
|
|
@@ -13,11 +21,14 @@ import {
|
|
|
13
21
|
type FetchParams,
|
|
14
22
|
type FetchRequestOptions,
|
|
15
23
|
type FetchResponse,
|
|
16
|
-
type FetchSearchParams,
|
|
17
|
-
type FetchSearchResponse,
|
|
18
24
|
type GetBatchedParams,
|
|
19
25
|
type GetBatchedResponse,
|
|
20
26
|
type RequestOptions,
|
|
27
|
+
type SnapshotCatalog,
|
|
28
|
+
type SnapshotCatalogParams,
|
|
29
|
+
type SnapshotGetParams,
|
|
30
|
+
type SnapshotHighlightsParams,
|
|
31
|
+
type SnapshotHighlightsResponse,
|
|
21
32
|
} from './Service.js'
|
|
22
33
|
|
|
23
34
|
export type {
|
|
@@ -31,15 +42,55 @@ export type {
|
|
|
31
42
|
FetchParams,
|
|
32
43
|
FetchRequestOptions,
|
|
33
44
|
FetchResponse,
|
|
34
|
-
FetchSearchParams,
|
|
35
|
-
FetchSearchResponse,
|
|
36
45
|
GetBatchedParams,
|
|
37
46
|
GetBatchedResponse,
|
|
47
|
+
SnapshotCatalog,
|
|
48
|
+
SnapshotCatalogParams,
|
|
49
|
+
SnapshotGetParams,
|
|
50
|
+
SnapshotHighlightsParams,
|
|
51
|
+
SnapshotHighlightsResponse,
|
|
38
52
|
} from './Service.js'
|
|
39
53
|
|
|
40
54
|
/** Options for constructing an {@link ExpandClient}. Same shape as `ExpandServiceOptions`. */
|
|
41
55
|
export type ExpandClientOptions = ExpandServiceOptions
|
|
42
56
|
|
|
57
|
+
export class SnapshotService {
|
|
58
|
+
constructor(
|
|
59
|
+
private readonly baseUrl: string,
|
|
60
|
+
private readonly readSnapshot: (
|
|
61
|
+
params: SnapshotGetParams,
|
|
62
|
+
options?: RequestOptions,
|
|
63
|
+
) => Promise<SnapshotCatalog | SnapshotHighlightsResponse>,
|
|
64
|
+
) {}
|
|
65
|
+
|
|
66
|
+
/** Read the public catalog, or request authenticated, billed Highlights from this Snapshot. */
|
|
67
|
+
get(params: SnapshotHighlightsParams, options?: RequestOptions): Promise<SnapshotHighlightsResponse>
|
|
68
|
+
get(params: SnapshotCatalogParams, options?: RequestOptions): Promise<SnapshotCatalog>
|
|
69
|
+
get(params: SnapshotGetParams, options?: RequestOptions): Promise<SnapshotCatalog | SnapshotHighlightsResponse>
|
|
70
|
+
get(params: SnapshotGetParams, options: RequestOptions = {}): Promise<SnapshotCatalog | SnapshotHighlightsResponse> {
|
|
71
|
+
return this.readSnapshot(params, options)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async network(params: { readonly snapshotId: string; readonly q?: string }): Promise<unknown> {
|
|
75
|
+
return this.request(`${params.snapshotId}/network${params.q ? `?q=${encodeURIComponent(params.q)}` : ''}`)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async preview(params: {
|
|
79
|
+
readonly snapshotId: string
|
|
80
|
+
readonly artifactId: string
|
|
81
|
+
readonly maxBytes?: number
|
|
82
|
+
}): Promise<unknown> {
|
|
83
|
+
const suffix = `/artifacts/${encodeURIComponent(params.artifactId)}/preview${params.maxBytes !== undefined ? `?maxBytes=${params.maxBytes}` : ''}`
|
|
84
|
+
return this.request(`${params.snapshotId}${suffix}`)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private async request<T>(path: string): Promise<T> {
|
|
88
|
+
const response = await globalThis.fetch(`${this.baseUrl.replace(/\/+$/u, '')}/v1/public/snapshots/${path}`)
|
|
89
|
+
if (!response.ok) throw new Error(`Snapshot request failed with HTTP ${response.status}`)
|
|
90
|
+
return (await response.json()) as T
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
43
94
|
/**
|
|
44
95
|
* Promise-based client for the Expand API. Holds an {@link InternalClient} layer and runs Effects
|
|
45
96
|
* against it, translating typed failures into the `ExpandClient*Error` hierarchy that async/await
|
|
@@ -53,6 +104,18 @@ export type ExpandClientOptions = ExpandServiceOptions
|
|
|
53
104
|
*/
|
|
54
105
|
export class ExpandClient {
|
|
55
106
|
private readonly layer: Layer.Layer<InternalClient>
|
|
107
|
+
private readonly lifetime = new AbortController()
|
|
108
|
+
private readonly activeRequests = new Set<Promise<unknown>>()
|
|
109
|
+
private dispatcher?: Undici.EnvHttpProxyAgent
|
|
110
|
+
private closePromise?: Promise<void>
|
|
111
|
+
/** Create, list and control crawls, and read their event streams. */
|
|
112
|
+
readonly crawls: CrawlsClient
|
|
113
|
+
/** Read stored crawl captures: Markdown, HTML, State JSON and evidence. */
|
|
114
|
+
readonly captures: ReturnType<typeof makeCrawlProjectionsClient>['captures']
|
|
115
|
+
/** Read a crawled page's versions and diffs. */
|
|
116
|
+
readonly pages: ReturnType<typeof makeCrawlProjectionsClient>['pages']
|
|
117
|
+
readonly snapshots: SnapshotService
|
|
118
|
+
private readonly projections: ReturnType<typeof makeCrawlProjectionsClient>
|
|
56
119
|
|
|
57
120
|
constructor(options: ExpandClientOptions = {}) {
|
|
58
121
|
// Eagerly validate so the Promise constructor matches the documented sync-throw contract.
|
|
@@ -65,6 +128,47 @@ export class ExpandClient {
|
|
|
65
128
|
// Layer.orDie narrows the error channel to `never` while keeping a die fallback as a defensive
|
|
66
129
|
// net (translateCause picks up dies via Cause.dieOption and converts via toPublicError).
|
|
67
130
|
this.layer = InternalClient.layer(options).pipe(Layer.orDie)
|
|
131
|
+
const apiKey = options.apiKey ?? process.env.EXPAND_API_KEY
|
|
132
|
+
if (apiKey === undefined) throw new Error('Missing Expand API key.')
|
|
133
|
+
const proxyAwareFetch: typeof globalThis.fetch = (input, init) => {
|
|
134
|
+
if (this.lifetime.signal.aborted) {
|
|
135
|
+
const reason: unknown = this.lifetime.signal.reason
|
|
136
|
+
return Promise.reject(reason instanceof Error ? reason : new ExpandClientError('The Expand client is closed.'))
|
|
137
|
+
}
|
|
138
|
+
this.dispatcher ??= new Undici.EnvHttpProxyAgent()
|
|
139
|
+
const requestSignal = init?.signal === undefined && input instanceof Request ? input.signal : init?.signal
|
|
140
|
+
const signal = AbortSignal.any([this.lifetime.signal, ...(requestSignal == null ? [] : [requestSignal])])
|
|
141
|
+
const proxyInit: RequestInit & { readonly dispatcher: Undici.Dispatcher } = {
|
|
142
|
+
...init,
|
|
143
|
+
signal,
|
|
144
|
+
dispatcher: this.dispatcher,
|
|
145
|
+
}
|
|
146
|
+
return globalThis.fetch(input, proxyInit)
|
|
147
|
+
}
|
|
148
|
+
const transport = {
|
|
149
|
+
apiKey,
|
|
150
|
+
baseUrl: options.baseUrl ?? 'https://api.expand.ai',
|
|
151
|
+
fetch: proxyAwareFetch,
|
|
152
|
+
timeoutMs: options.timeoutMs,
|
|
153
|
+
maxRetries: options.maxRetries,
|
|
154
|
+
signal: this.lifetime.signal,
|
|
155
|
+
}
|
|
156
|
+
this.crawls = makeCrawlsClient(transport)
|
|
157
|
+
this.projections = makeCrawlProjectionsClient(transport)
|
|
158
|
+
this.captures = this.projections.captures
|
|
159
|
+
this.pages = this.projections.pages
|
|
160
|
+
this.snapshots = new SnapshotService(options.baseUrl ?? 'https://api.expand.ai', (params, requestOptions) =>
|
|
161
|
+
this.run((client) => client.getSnapshot(params, requestOptions)),
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Abort active requests and release this client's HTTP connections. Repeated calls are safe. */
|
|
166
|
+
close(): Promise<void> {
|
|
167
|
+
if (!this.lifetime.signal.aborted) this.lifetime.abort(new ExpandClientError('The Expand client is closed.'))
|
|
168
|
+
this.closePromise ??= Promise.all([...this.activeRequests, this.dispatcher?.destroy() ?? Promise.resolve()]).then(
|
|
169
|
+
() => undefined,
|
|
170
|
+
)
|
|
171
|
+
return this.closePromise
|
|
68
172
|
}
|
|
69
173
|
|
|
70
174
|
/**
|
|
@@ -83,18 +187,18 @@ export class ExpandClient {
|
|
|
83
187
|
* Fetch a URL through the object-mode endpoint.
|
|
84
188
|
*
|
|
85
189
|
* Returns `{ meta, markdown, json }`; use {@link fetch} for the raw markdown document.
|
|
190
|
+
* A string argument instead reads an existing JSON projection from this API without starting a capture.
|
|
86
191
|
*/
|
|
87
|
-
|
|
192
|
+
fetchJson(params: FetchJsonParams, options?: FetchJsonRequestOptions): Promise<FetchJsonResponse>
|
|
193
|
+
fetchJson(url: string, options?: CrawlProjectionOptions): Promise<unknown>
|
|
194
|
+
async fetchJson(
|
|
195
|
+
params: FetchJsonParams | string,
|
|
196
|
+
options: FetchJsonRequestOptions & CrawlProjectionOptions = {},
|
|
197
|
+
): Promise<unknown> {
|
|
198
|
+
if (typeof params === 'string') return this.projections.fetchJson(params, options)
|
|
88
199
|
return this.run((client) => client.fetchJson(params, options))
|
|
89
200
|
}
|
|
90
201
|
|
|
91
|
-
/**
|
|
92
|
-
* Search a previous Fetch response by `snapshotId` without starting a new capture.
|
|
93
|
-
*/
|
|
94
|
-
async fetchSearch(params: FetchSearchParams, options: RequestOptions = {}): Promise<FetchSearchResponse> {
|
|
95
|
-
return this.run((client) => client.fetchSearch(params, options))
|
|
96
|
-
}
|
|
97
|
-
|
|
98
202
|
/**
|
|
99
203
|
* Kick off an asynchronous batched fetch over one or more URLs. Returns a batched-run ID that can
|
|
100
204
|
* be polled via {@link getBatched}.
|
|
@@ -132,10 +236,21 @@ export class ExpandClient {
|
|
|
132
236
|
}
|
|
133
237
|
|
|
134
238
|
private async run<A>(body: (ic: InternalClient) => Effect.Effect<A, Error>): Promise<A> {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
239
|
+
this.lifetime.signal.throwIfAborted()
|
|
240
|
+
const running = Effect.runPromiseExit(InternalClient.use(body).pipe(Effect.provide(this.layer)), {
|
|
241
|
+
signal: this.lifetime.signal,
|
|
242
|
+
})
|
|
243
|
+
this.activeRequests.add(running)
|
|
244
|
+
try {
|
|
245
|
+
const exit = await running
|
|
246
|
+
this.lifetime.signal.throwIfAborted()
|
|
247
|
+
if (Exit.isSuccess(exit)) return exit.value
|
|
248
|
+
throw InternalClient.translateCause(exit.cause)
|
|
249
|
+
} finally {
|
|
250
|
+
this.activeRequests.delete(running)
|
|
138
251
|
}
|
|
139
|
-
throw InternalClient.translateCause(exit.cause)
|
|
140
252
|
}
|
|
141
253
|
}
|
|
254
|
+
|
|
255
|
+
/** Concise public name used by the Crawl API examples. */
|
|
256
|
+
export class Expand extends ExpandClient {}
|