@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/web-capture",
3
- "version": "1.10.12",
3
+ "version": "1.11.1",
4
4
  "description": "CLI and microservice to render web pages as HTML, Markdown, or PNG",
5
5
  "main": "src/index.js",
6
6
  "bin": {
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 response = await fetchHtmlResponse(url);
37
- return response.text();
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 fetch(url, {
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.fetchImpl=fetch] - Fetch implementation (injectable for tests)
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
- fetchImpl = fetch,
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 response = await fetchImpl(sourceUrl, {
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 = response.status;
356
- const body = await response.text();
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 {
@@ -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 { fetchHtml } from '../../src/lib.js';
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
  });