@link-assistant/web-capture 1.10.12 → 1.11.1
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/CHANGELOG.md +12 -0
- package/README.md +27 -0
- package/package.json +1 -1
- package/src/index.js +7 -0
- package/src/lib.js +13 -6
- package/src/search.js +34 -6
- package/src/transport.js +108 -0
- package/tests/unit/fetch-html.test.js +31 -1
- package/tests/unit/search.test.js +71 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @link-assistant/web-capture
|
|
2
2
|
|
|
3
|
+
## 1.11.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Auto-release unreleased changes
|
|
8
|
+
|
|
9
|
+
## 1.11.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 66d8144: Add caller-owned transports, AbortSignal cancellation, and exact-byte response receipts to capture and search library APIs.
|
|
14
|
+
|
|
3
15
|
## 1.10.12
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -568,6 +568,33 @@ const buffer = await page.screenshot({ type: 'png', fullPage: true });
|
|
|
568
568
|
await browser.close();
|
|
569
569
|
```
|
|
570
570
|
|
|
571
|
+
### Exact response receipts and cancellation
|
|
572
|
+
|
|
573
|
+
Library callers can retain response provenance without decoding the source
|
|
574
|
+
through a text API. A receipt contains the exact `Buffer` body, final URL, HTTP
|
|
575
|
+
status, selected cache/content headers, and structured diagnostics:
|
|
576
|
+
|
|
577
|
+
```javascript
|
|
578
|
+
import { fetchHtmlReceipt, search } from '@link-assistant/web-capture';
|
|
579
|
+
|
|
580
|
+
const controller = new AbortController();
|
|
581
|
+
const receipt = await fetchHtmlReceipt('https://example.com', {
|
|
582
|
+
signal: controller.signal,
|
|
583
|
+
// transport: ({ url, method, headers, signal }) => myTransport(...),
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
const result = await search({
|
|
587
|
+
query: 'formal methods',
|
|
588
|
+
signal: controller.signal,
|
|
589
|
+
});
|
|
590
|
+
console.log(receipt.status, result.receipt.body, result.receipt.finalUrl);
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
`transport` may return either a Fetch-compatible `Response` or a materialized
|
|
594
|
+
receipt. The search receipt is available to library callers as
|
|
595
|
+
`result.receipt`; it is intentionally non-enumerable so the stable `/search`
|
|
596
|
+
JSON response does not duplicate the full provider body.
|
|
597
|
+
|
|
571
598
|
## License
|
|
572
599
|
|
|
573
600
|
[Unlicense](../LICENSE) — This is free and unencumbered software released into the public domain. You are free to copy, modify, publish, use, compile, sell, or distribute this software for any purpose, commercial or non-commercial, and by any means. See [https://unlicense.org](https://unlicense.org) for details.
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -80,6 +80,13 @@ export {
|
|
|
80
80
|
formatSearchAsMarkdown,
|
|
81
81
|
SEARCH_PROVIDERS,
|
|
82
82
|
} from './search.js';
|
|
83
|
+
export {
|
|
84
|
+
captureResponse,
|
|
85
|
+
fetchTransport,
|
|
86
|
+
CaptureTransportError,
|
|
87
|
+
RECEIPT_HEADERS,
|
|
88
|
+
} from './transport.js';
|
|
89
|
+
export { fetchHtml, fetchHtmlReceipt } from './lib.js';
|
|
83
90
|
export {
|
|
84
91
|
captureSharedDialog,
|
|
85
92
|
formatSharedDialogAsDemoMemory,
|
package/src/lib.js
CHANGED
|
@@ -11,6 +11,7 @@ import { isFormulaImage, isMathElement, extractFormula } from './latex.js';
|
|
|
11
11
|
import { extractMetadata } from './metadata.js';
|
|
12
12
|
import { postProcessMarkdown } from './postprocess.js';
|
|
13
13
|
import { retry } from './retry.js';
|
|
14
|
+
import { captureResponse, fetchTransport } from './transport.js';
|
|
14
15
|
|
|
15
16
|
const STACKPRINTER_RETRIES = 3;
|
|
16
17
|
const STACKPRINTER_RETRY_BASE_DELAY_MS = 1000;
|
|
@@ -19,7 +20,7 @@ const HTML_FETCH_HEADERS = {
|
|
|
19
20
|
'accept-encoding': 'identity',
|
|
20
21
|
};
|
|
21
22
|
|
|
22
|
-
export async function fetchHtml(url) {
|
|
23
|
+
export async function fetchHtml(url, options = {}) {
|
|
23
24
|
if (!url) {
|
|
24
25
|
throw new Error('Missing URL parameter');
|
|
25
26
|
}
|
|
@@ -33,8 +34,16 @@ export async function fetchHtml(url) {
|
|
|
33
34
|
return await fetchStackPrinterHtml(stackPrinterUrl);
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
const
|
|
37
|
-
return
|
|
37
|
+
const receipt = await fetchHtmlReceipt(url, options);
|
|
38
|
+
return receipt.body.toString('utf8');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Return an undecoded, exact-byte receipt for a document fetch. */
|
|
42
|
+
export function fetchHtmlReceipt(url, options = {}) {
|
|
43
|
+
return captureResponse(url, {
|
|
44
|
+
...options,
|
|
45
|
+
headers: { ...HTML_FETCH_HEADERS, ...options.headers },
|
|
46
|
+
});
|
|
38
47
|
}
|
|
39
48
|
|
|
40
49
|
export function getGoogleDriveFileId(url) {
|
|
@@ -216,9 +225,7 @@ async function fetchStackPrinterHtml(url) {
|
|
|
216
225
|
}
|
|
217
226
|
|
|
218
227
|
function fetchHtmlResponse(url) {
|
|
219
|
-
return
|
|
220
|
-
headers: HTML_FETCH_HEADERS,
|
|
221
|
-
});
|
|
228
|
+
return fetchTransport({ url, headers: HTML_FETCH_HEADERS });
|
|
222
229
|
}
|
|
223
230
|
|
|
224
231
|
function isStackPrinterTransientError(html) {
|
package/src/search.js
CHANGED
|
@@ -19,10 +19,10 @@
|
|
|
19
19
|
* @module search
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import fetch from 'node-fetch';
|
|
23
22
|
import * as cheerio from 'cheerio';
|
|
24
23
|
import he from 'he';
|
|
25
24
|
import { URL } from 'url';
|
|
25
|
+
import { captureResponse, fetchTransport } from './transport.js';
|
|
26
26
|
|
|
27
27
|
/** Providers understood by the search contract. */
|
|
28
28
|
export const SEARCH_PROVIDERS = [
|
|
@@ -310,7 +310,9 @@ export function formatSearchAsMarkdown(result) {
|
|
|
310
310
|
* @param {string} [options.provider=wikipedia] - Provider id
|
|
311
311
|
* @param {number} [options.limit=10] - Max results to return
|
|
312
312
|
* @param {string} [options.captureMode=fetch] - Reported capture mode
|
|
313
|
-
* @param {Function} [options.
|
|
313
|
+
* @param {Function} [options.transport] - Receipt transport (injectable)
|
|
314
|
+
* @param {AbortSignal} [options.signal] - Caller cancellation signal
|
|
315
|
+
* @param {Function} [options.fetchImpl] - Legacy Fetch-compatible injection
|
|
314
316
|
* @param {Function} [options.now] - Clock returning an ISO timestamp (injectable for tests)
|
|
315
317
|
* @returns {Promise<Object>} Normalized search result object
|
|
316
318
|
*/
|
|
@@ -319,7 +321,9 @@ export async function search({
|
|
|
319
321
|
provider = DEFAULT_PROVIDER,
|
|
320
322
|
limit = DEFAULT_LIMIT,
|
|
321
323
|
captureMode = 'fetch',
|
|
322
|
-
|
|
324
|
+
transport,
|
|
325
|
+
signal,
|
|
326
|
+
fetchImpl,
|
|
323
327
|
now = () => new Date().toISOString(),
|
|
324
328
|
} = {}) {
|
|
325
329
|
if (!query || !String(query).trim()) {
|
|
@@ -342,7 +346,14 @@ export async function search({
|
|
|
342
346
|
|
|
343
347
|
let results = [];
|
|
344
348
|
try {
|
|
345
|
-
const
|
|
349
|
+
const effectiveTransport = transport
|
|
350
|
+
? transport
|
|
351
|
+
: fetchImpl
|
|
352
|
+
? ({ url, ...init }) => fetchImpl(url, init)
|
|
353
|
+
: fetchTransport;
|
|
354
|
+
const receipt = await captureResponse(sourceUrl, {
|
|
355
|
+
transport: effectiveTransport,
|
|
356
|
+
signal,
|
|
346
357
|
headers: {
|
|
347
358
|
'User-Agent': USER_AGENT,
|
|
348
359
|
Accept:
|
|
@@ -352,16 +363,33 @@ export async function search({
|
|
|
352
363
|
'Accept-Language': 'en-US,en;q=0.9',
|
|
353
364
|
},
|
|
354
365
|
});
|
|
355
|
-
diagnostics.status =
|
|
356
|
-
const body =
|
|
366
|
+
diagnostics.status = receipt.status;
|
|
367
|
+
const body = receipt.body.toString('utf8');
|
|
357
368
|
const parsed = parseSearchResults(provider, body, { limit });
|
|
358
369
|
results = parsed.results;
|
|
359
370
|
diagnostics.blockedByCaptcha = parsed.blockedByCaptcha;
|
|
371
|
+
const result = {
|
|
372
|
+
query,
|
|
373
|
+
provider,
|
|
374
|
+
captureMode,
|
|
375
|
+
capturedAt,
|
|
376
|
+
results,
|
|
377
|
+
diagnostics,
|
|
378
|
+
};
|
|
379
|
+
// Library callers can bind parsed results to the exact receipt without
|
|
380
|
+
// inflating the stable HTTP /search JSON representation.
|
|
381
|
+
Object.defineProperty(result, 'receipt', {
|
|
382
|
+
value: receipt,
|
|
383
|
+
enumerable: false,
|
|
384
|
+
});
|
|
385
|
+
return result;
|
|
360
386
|
} catch (err) {
|
|
361
387
|
// A network/transport failure from a server context is reported as an
|
|
362
388
|
// error status; browser CORS failures surface the same way and are flagged.
|
|
363
389
|
diagnostics.status = diagnostics.status || 0;
|
|
364
390
|
diagnostics.error = err.message;
|
|
391
|
+
diagnostics.errorKind = err.diagnostics?.errorKind || 'transport';
|
|
392
|
+
diagnostics.blockedByCors = diagnostics.errorKind === 'cors_or_transport';
|
|
365
393
|
}
|
|
366
394
|
|
|
367
395
|
return {
|
package/src/transport.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import fetch from 'node-fetch';
|
|
2
|
+
|
|
3
|
+
export const RECEIPT_HEADERS = [
|
|
4
|
+
'cache-control',
|
|
5
|
+
'content-encoding',
|
|
6
|
+
'content-length',
|
|
7
|
+
'content-type',
|
|
8
|
+
'etag',
|
|
9
|
+
'last-modified',
|
|
10
|
+
'location',
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
function selectedHeaders(headers) {
|
|
14
|
+
const selected = {};
|
|
15
|
+
for (const name of RECEIPT_HEADERS) {
|
|
16
|
+
const value =
|
|
17
|
+
typeof headers?.get === 'function' ? headers.get(name) : headers?.[name];
|
|
18
|
+
if (value !== null && value !== undefined) {
|
|
19
|
+
selected[name] = String(value);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return selected;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function classifyTransportError(error, signal) {
|
|
26
|
+
if (signal?.aborted || error?.name === 'AbortError') {
|
|
27
|
+
return 'cancelled';
|
|
28
|
+
}
|
|
29
|
+
if (error?.name === 'TimeoutError' || error?.type === 'request-timeout') {
|
|
30
|
+
return 'timeout';
|
|
31
|
+
}
|
|
32
|
+
if (error instanceof TypeError) {
|
|
33
|
+
return 'cors_or_transport';
|
|
34
|
+
}
|
|
35
|
+
return 'transport';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class CaptureTransportError extends Error {
|
|
39
|
+
constructor(error, url, signal) {
|
|
40
|
+
super(error?.message || String(error));
|
|
41
|
+
this.name = 'CaptureTransportError';
|
|
42
|
+
this.cause = error;
|
|
43
|
+
this.diagnostics = {
|
|
44
|
+
outcome: 'error',
|
|
45
|
+
errorKind: classifyTransportError(error, signal),
|
|
46
|
+
sourceUrl: url,
|
|
47
|
+
error: this.message,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Default caller-replaceable transport. Dropping/aborting `signal` cancels fetch. */
|
|
53
|
+
export function fetchTransport({ url, method = 'GET', headers, signal }) {
|
|
54
|
+
return fetch(url, { method, headers, signal });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Capture an HTTP response without decoding its body.
|
|
59
|
+
*
|
|
60
|
+
* A custom transport receives `{url, method, headers, signal}` and may return
|
|
61
|
+
* either a Fetch `Response` or an already materialized receipt. The returned
|
|
62
|
+
* body is a Buffer containing the exact response bytes.
|
|
63
|
+
*/
|
|
64
|
+
export async function captureResponse(
|
|
65
|
+
url,
|
|
66
|
+
{ transport = fetchTransport, signal, headers = {} } = {}
|
|
67
|
+
) {
|
|
68
|
+
if (!url) {
|
|
69
|
+
throw new Error('Missing URL parameter');
|
|
70
|
+
}
|
|
71
|
+
const request = { url, method: 'GET', headers, signal };
|
|
72
|
+
try {
|
|
73
|
+
const response = await transport(request);
|
|
74
|
+
if (
|
|
75
|
+
response?.body !== null &&
|
|
76
|
+
response?.body !== undefined &&
|
|
77
|
+
response?.finalUrl &&
|
|
78
|
+
response?.diagnostics
|
|
79
|
+
) {
|
|
80
|
+
return { ...response, body: Buffer.from(response.body) };
|
|
81
|
+
}
|
|
82
|
+
if (
|
|
83
|
+
!response ||
|
|
84
|
+
(typeof response.arrayBuffer !== 'function' &&
|
|
85
|
+
typeof response.text !== 'function')
|
|
86
|
+
) {
|
|
87
|
+
throw new TypeError(
|
|
88
|
+
'Transport must return a Response or response receipt'
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const body =
|
|
92
|
+
typeof response.arrayBuffer === 'function'
|
|
93
|
+
? Buffer.from(await response.arrayBuffer())
|
|
94
|
+
: Buffer.from(await response.text());
|
|
95
|
+
return {
|
|
96
|
+
body,
|
|
97
|
+
finalUrl: response.url || url,
|
|
98
|
+
status: response.status,
|
|
99
|
+
headers: selectedHeaders(response.headers),
|
|
100
|
+
diagnostics: { outcome: 'response' },
|
|
101
|
+
};
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (error instanceof CaptureTransportError) {
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
throw new CaptureTransportError(error, url, signal);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import nock from 'nock';
|
|
2
|
-
import {
|
|
2
|
+
import { jest } from '@jest/globals';
|
|
3
|
+
import { fetchHtml, fetchHtmlReceipt } from '../../src/lib.js';
|
|
3
4
|
|
|
4
5
|
afterEach(() => {
|
|
5
6
|
nock.cleanAll();
|
|
@@ -21,4 +22,33 @@ describe('fetchHtml', () => {
|
|
|
21
22
|
'ok'
|
|
22
23
|
);
|
|
23
24
|
});
|
|
25
|
+
|
|
26
|
+
it('returns an exact-byte receipt through an injectable transport', async () => {
|
|
27
|
+
const body = Buffer.from([0x00, 0xff, 0x41]);
|
|
28
|
+
const transport = jest.fn(async ({ url, signal }) => ({
|
|
29
|
+
body,
|
|
30
|
+
finalUrl: `${url}/final`,
|
|
31
|
+
status: 206,
|
|
32
|
+
headers: { 'content-type': 'application/octet-stream', etag: 'v1' },
|
|
33
|
+
diagnostics: { outcome: 'response' },
|
|
34
|
+
signal,
|
|
35
|
+
}));
|
|
36
|
+
const controller = new globalThis.AbortController();
|
|
37
|
+
|
|
38
|
+
const receipt = await fetchHtmlReceipt('https://example.com/bytes', {
|
|
39
|
+
transport,
|
|
40
|
+
signal: controller.signal,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(Buffer.from(receipt.body)).toEqual(body);
|
|
44
|
+
expect(receipt).toMatchObject({
|
|
45
|
+
finalUrl: 'https://example.com/bytes/final',
|
|
46
|
+
status: 206,
|
|
47
|
+
headers: { 'content-type': 'application/octet-stream', etag: 'v1' },
|
|
48
|
+
diagnostics: { outcome: 'response' },
|
|
49
|
+
});
|
|
50
|
+
expect(transport).toHaveBeenCalledWith(
|
|
51
|
+
expect.objectContaining({ signal: controller.signal })
|
|
52
|
+
);
|
|
53
|
+
});
|
|
24
54
|
});
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Parsers are pure functions tested against fixtures; the orchestrating
|
|
5
5
|
* `search()` is exercised with an injected fetch so no network is required.
|
|
6
6
|
*/
|
|
7
|
+
import { jest } from '@jest/globals';
|
|
7
8
|
|
|
8
9
|
import {
|
|
9
10
|
search,
|
|
@@ -75,6 +76,25 @@ describe('search module (#130)', () => {
|
|
|
75
76
|
});
|
|
76
77
|
});
|
|
77
78
|
|
|
79
|
+
it('keeps URL construction and response parsing transport-independent', () => {
|
|
80
|
+
const sourceUrl = buildSearchUrl('wikipedia', 'formal ai', 1);
|
|
81
|
+
const { results } = parseSearchResults('wikipedia', WIKI_JSON, {
|
|
82
|
+
limit: 1,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(sourceUrl).toBe(
|
|
86
|
+
'https://en.wikipedia.org/w/rest.php/v1/search/page?q=formal%20ai&limit=1'
|
|
87
|
+
);
|
|
88
|
+
expect(results).toEqual([
|
|
89
|
+
{
|
|
90
|
+
rank: 1,
|
|
91
|
+
title: 'Formal methods',
|
|
92
|
+
url: 'https://en.wikipedia.org/wiki/Formal_methods',
|
|
93
|
+
snippet: 'the study of formal',
|
|
94
|
+
},
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
|
|
78
98
|
describe('parseSearchResults', () => {
|
|
79
99
|
it('normalizes Wikipedia REST JSON and strips markup', () => {
|
|
80
100
|
const { results } = parseSearchResults('wikipedia', WIKI_JSON, {
|
|
@@ -205,6 +225,57 @@ describe('search module (#130)', () => {
|
|
|
205
225
|
expect(result.diagnostics.error).toBe('network down');
|
|
206
226
|
});
|
|
207
227
|
|
|
228
|
+
it('returns the source receipt and forwards caller cancellation', async () => {
|
|
229
|
+
const body = Buffer.from(WIKI_JSON);
|
|
230
|
+
const controller = new globalThis.AbortController();
|
|
231
|
+
const transport = jest.fn(async ({ url, signal }) => ({
|
|
232
|
+
body,
|
|
233
|
+
finalUrl: `${url}&redirected=true`,
|
|
234
|
+
status: 200,
|
|
235
|
+
headers: { 'content-type': 'application/json', etag: 'search-v1' },
|
|
236
|
+
diagnostics: { outcome: 'response' },
|
|
237
|
+
signal,
|
|
238
|
+
}));
|
|
239
|
+
|
|
240
|
+
const result = await search({
|
|
241
|
+
query: 'formal-ai',
|
|
242
|
+
transport,
|
|
243
|
+
signal: controller.signal,
|
|
244
|
+
now: fixedNow,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
expect(Buffer.from(result.receipt.body)).toEqual(body);
|
|
248
|
+
expect(result.receipt.finalUrl).toContain('redirected=true');
|
|
249
|
+
expect(result.receipt.headers.etag).toBe('search-v1');
|
|
250
|
+
expect(transport).toHaveBeenCalledWith(
|
|
251
|
+
expect.objectContaining({ signal: controller.signal })
|
|
252
|
+
);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('classifies AbortSignal cancellation in structured diagnostics', async () => {
|
|
256
|
+
const controller = new globalThis.AbortController();
|
|
257
|
+
controller.abort();
|
|
258
|
+
const transport = async ({ signal }) => {
|
|
259
|
+
expect(signal).toBe(controller.signal);
|
|
260
|
+
const error = new Error('cancelled by caller');
|
|
261
|
+
error.name = 'AbortError';
|
|
262
|
+
throw error;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const result = await search({
|
|
266
|
+
query: 'formal-ai',
|
|
267
|
+
transport,
|
|
268
|
+
signal: controller.signal,
|
|
269
|
+
now: fixedNow,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
expect(result.diagnostics).toMatchObject({
|
|
273
|
+
status: 0,
|
|
274
|
+
errorKind: 'cancelled',
|
|
275
|
+
error: 'cancelled by caller',
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
208
279
|
it('rejects an empty query', async () => {
|
|
209
280
|
await expect(search({ query: ' ' })).rejects.toThrow(/Missing .query/);
|
|
210
281
|
});
|