@link-assistant/web-capture 1.10.9 → 1.10.10

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,11 @@
1
1
  # @link-assistant/web-capture
2
2
 
3
+ ## 1.10.10
4
+
5
+ ### Patch Changes
6
+
7
+ - 65d7dcc: Add shared AI dialog capture for ChatGPT share links with structured transcript output and unsupported-provider diagnostics.
8
+
3
9
  ## 1.10.9
4
10
 
5
11
  ### Patch Changes
package/README.md CHANGED
@@ -15,6 +15,7 @@ A CLI and microservice to fetch URLs and render them as:
15
15
  - **ZIP archive**: Markdown + locally downloaded images
16
16
  - **PDF**: Print-quality document with embedded images
17
17
  - **DOCX**: Word document with embedded images
18
+ - **Shared AI dialog transcript**: ChatGPT share captures with structured diagnostics for unsupported providers
18
19
 
19
20
  ## Installation
20
21
 
@@ -99,6 +100,10 @@ web-capture https://example.com --no-extract-latex --no-post-process -o page.md
99
100
  # Structured search-provider capture (JSON by default)
100
101
  web-capture search "formal methods" --provider wikipedia
101
102
 
103
+ # Shared AI dialog capture (JSON by default)
104
+ web-capture shared-dialog https://chatgpt.com/share/SHARE_ID
105
+ web-capture shared-dialog https://chatgpt.com/share/SHARE_ID --format demo-memory -o -
106
+
102
107
  # Start as API server
103
108
  web-capture --serve
104
109
 
@@ -321,6 +326,17 @@ Example response (`format=json`):
321
326
  }
322
327
  ```
323
328
 
329
+ ### Shared Dialog Endpoint
330
+
331
+ ```
332
+ GET /shared-dialog?url=<URL>&format=json|meta-language|demo-memory|markdown|txt
333
+ ```
334
+
335
+ Captures replayable shared AI dialog transcripts. ChatGPT share links are
336
+ decoded from their embedded `linear_conversation` data. Providers that cannot
337
+ currently expose transcript data, such as Google AI Mode interstitial pages,
338
+ return a structured unsupported diagnostic.
339
+
324
340
  ## CLI Reference
325
341
 
326
342
  ### Server Mode
@@ -1,8 +1,4 @@
1
1
  #!/usr/bin/env node
2
- // CLI entry point for web-capture
3
- // Supports two modes:
4
- // 1. Server mode: web-capture --serve [--port 3000]
5
- // 2. Capture mode: web-capture <url> [options]
6
2
 
7
3
  import fs from 'fs';
8
4
  import path from 'path';
@@ -27,7 +23,11 @@ function makeVerboseLog(enabled) {
27
23
  });
28
24
  }
29
25
 
30
- // Create configuration using lino-arguments pattern
26
+ const originalConsoleLog = console.log;
27
+ console.log = (...args) =>
28
+ String(args[0]).includes(' variables from ')
29
+ ? console.error(...args)
30
+ : originalConsoleLog(...args);
31
31
  const config = makeConfig({
32
32
  yargs: ({ yargs, getenv }) =>
33
33
  yargs
@@ -49,6 +49,15 @@ const config = makeConfig({
49
49
  description: 'Search query',
50
50
  })
51
51
  )
52
+ .command(
53
+ 'shared-dialog <sharedDialogUrl>',
54
+ 'Capture replayable shared AI dialog transcripts',
55
+ (yargs) =>
56
+ yargs.positional('sharedDialogUrl', {
57
+ type: 'string',
58
+ description: 'Shared AI dialog URL',
59
+ })
60
+ )
52
61
  .option('provider', {
53
62
  type: 'string',
54
63
  description:
@@ -78,7 +87,7 @@ const config = makeConfig({
78
87
  alias: 'f',
79
88
  type: 'string',
80
89
  description:
81
- 'Output format: markdown, md, html, txt, text, image, png, jpeg, pdf, docx, archive',
90
+ 'Output format: markdown, md, html, txt, text, image, png, jpeg, pdf, docx, archive, json, meta-language, demo-memory',
82
91
  default: 'markdown',
83
92
  })
84
93
  .option('theme', {
@@ -289,7 +298,8 @@ const config = makeConfig({
289
298
  'Capture private Google Doc with API token'
290
299
  )
291
300
  .epilogue(
292
- 'API Endpoints (in server mode):\n GET /html?url=<URL>&engine=<ENGINE> Get rendered HTML\n GET /markdown?url=<URL> Get Markdown conversion\n GET /txt?url=<URL> Get text content\n GET /image?url=<URL>&format=png|jpeg&theme=light|dark Screenshot\n GET /archive?url=<URL>&localImages=true&documentFormat=markdown|html ZIP archive\n GET /pdf?url=<URL>&theme=light|dark PDF with embedded images\n GET /docx?url=<URL> DOCX with embedded images\n GET /fetch?url=<URL> Proxy fetch\n GET /stream?url=<URL> Streaming proxy'
301
+ 'API Endpoints (in server mode):\n GET /html?url=<URL>&engine=<ENGINE> Get rendered HTML\n GET /markdown?url=<URL> Get Markdown conversion\n GET /txt?url=<URL> Get text content\n GET /image?url=<URL>&format=png|jpeg&theme=light|dark Screenshot\n GET /archive?url=<URL>&localImages=true&documentFormat=markdown|html ZIP archive\n GET /pdf?url=<URL>&theme=light|dark PDF with embedded images\n GET /docx?url=<URL> DOCX with embedded images\n GET /fetch?url=<URL> Proxy fetch\n GET /stream?url=<URL> Streaming proxy' +
302
+ '\n GET /shared-dialog?url=<URL>&format=json|meta-language|demo-memory|markdown|txt Shared AI dialog capture'
293
303
  )
294
304
  .strict(),
295
305
  lenv: {
@@ -297,6 +307,7 @@ const config = makeConfig({
297
307
  path: '.lenv',
298
308
  },
299
309
  });
310
+ console.log = originalConsoleLog;
300
311
 
301
312
  function getUrlArgument() {
302
313
  if (typeof config.url === 'string' && config.url.length > 0) {
@@ -331,6 +342,7 @@ async function startServer(port) {
331
342
  console.log(` GET /docx?url=<URL> - DOCX with embedded images`);
332
343
  console.log(` GET /fetch?url=<URL> - Proxy fetch content`);
333
344
  console.log(` GET /stream?url=<URL> - Stream content`);
345
+ console.log(` GET /shared-dialog?url=<URL> - Shared AI dialog capture`);
334
346
  console.log('');
335
347
  console.log('Press Ctrl+C to stop the server');
336
348
  resolve(server);
@@ -1370,14 +1382,8 @@ async function captureUrl(url, options) {
1370
1382
 
1371
1383
  async function runSearch(query) {
1372
1384
  const { search, formatSearchAsMarkdown } = await import('../src/search.js');
1373
- // `--format` defaults to `markdown` for capture mode, but search results are
1374
- // primarily structured JSON. Only honor the format flag when the user passed
1375
- // it explicitly; otherwise default search output to JSON.
1376
- const formatPassedExplicitly = process.argv.some(
1377
- (arg) => arg === '-f' || arg === '--format' || arg.startsWith('--format=')
1378
- );
1379
1385
  const format = (
1380
- formatPassedExplicitly ? config.format : 'json'
1386
+ formatPassedExplicitly() ? config.format : 'json'
1381
1387
  ).toLowerCase();
1382
1388
  const result = await search({
1383
1389
  query,
@@ -1391,13 +1397,40 @@ async function runSearch(query) {
1391
1397
  }
1392
1398
  }
1393
1399
 
1400
+ async function runSharedDialog(sharedDialogUrl) {
1401
+ const { runSharedDialogCli } = await import('../src/shared-dialog-cli.js');
1402
+ await runSharedDialogCli(sharedDialogUrl, {
1403
+ capture: config.capture,
1404
+ engine: config.engine,
1405
+ format: formatPassedExplicitly() ? config.format : 'json',
1406
+ output: config.output,
1407
+ });
1408
+ }
1409
+
1410
+ function formatPassedExplicitly() {
1411
+ return process.argv.some(
1412
+ (arg) => arg === '-f' || arg === '--format' || arg.startsWith('--format=')
1413
+ );
1414
+ }
1415
+
1394
1416
  async function main() {
1395
1417
  const url = getUrlArgument();
1396
- // The `query` key is only present when the `search <query>` command matched.
1397
1418
  const isSearch = Object.prototype.hasOwnProperty.call(config, 'query');
1419
+ const isSharedDialog = Object.prototype.hasOwnProperty.call(
1420
+ config,
1421
+ 'sharedDialogUrl'
1422
+ );
1398
1423
 
1399
- if (isSearch) {
1400
- // Search mode
1424
+ if (isSharedDialog) {
1425
+ const sharedDialogUrl =
1426
+ typeof config.sharedDialogUrl === 'string' ? config.sharedDialogUrl : '';
1427
+ if (!sharedDialogUrl.trim()) {
1428
+ console.error('Error: Missing shared-dialog URL');
1429
+ console.error('Usage: web-capture shared-dialog <url> [--format <fmt>]');
1430
+ process.exit(1);
1431
+ }
1432
+ await runSharedDialog(sharedDialogUrl);
1433
+ } else if (isSearch) {
1401
1434
  const query = typeof config.query === 'string' ? config.query : '';
1402
1435
  if (!query.trim()) {
1403
1436
  console.error('Error: Missing search query');
@@ -1406,7 +1439,6 @@ async function main() {
1406
1439
  }
1407
1440
  await runSearch(query);
1408
1441
  } else if (config.serve) {
1409
- // Server mode
1410
1442
  await startServer(config.port);
1411
1443
  } else if (url) {
1412
1444
  // --no-extract-images is an alias for --embed-images
@@ -1429,7 +1461,6 @@ async function main() {
1429
1461
  config.archiveFormat = archiveFormat === 'gz' ? 'tar.gz' : archiveFormat;
1430
1462
  config.format = 'archive';
1431
1463
  }
1432
- // Capture mode
1433
1464
  await captureUrl(url, {
1434
1465
  format: config.format,
1435
1466
  output: config.output,
@@ -1457,7 +1488,6 @@ async function main() {
1457
1488
  capture: config.capture,
1458
1489
  });
1459
1490
  } else {
1460
- // No arguments - show error
1461
1491
  console.error('Error: Missing URL or --serve flag');
1462
1492
  console.error('Run with --help for usage information');
1463
1493
  process.exit(1);
@@ -5,5 +5,7 @@ services:
5
5
  dockerfile: Dockerfile
6
6
  ports:
7
7
  - '3000:3000'
8
+ extra_hosts:
9
+ - 'host.docker.internal:host-gateway'
8
10
  environment:
9
11
  - PORT=3000
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/web-capture",
3
- "version": "1.10.9",
3
+ "version": "1.10.10",
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/fetch.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import fetch from 'node-fetch';
2
2
  import { convertGoogleDriveUrl } from './lib.js';
3
+ import { copyProxyResponseHeaders } from './proxy-headers.js';
3
4
 
4
5
  export async function fetchHandler(req, res) {
5
6
  const url = req.query.url;
@@ -10,21 +11,7 @@ export async function fetchHandler(req, res) {
10
11
  const response = await fetch(convertGoogleDriveUrl(url));
11
12
  // Copy status and headers
12
13
  res.status(response.status);
13
-
14
- // Set default content type if not present
15
- const contentType = response.headers.get('content-type') || 'text/plain';
16
- res.setHeader('Content-Type', contentType);
17
-
18
- // Copy other headers
19
- for (const [key, value] of response.headers.entries()) {
20
- if (
21
- key.toLowerCase() !== 'transfer-encoding' &&
22
- key.toLowerCase() !== 'content-encoding' &&
23
- key.toLowerCase() !== 'content-length'
24
- ) {
25
- res.setHeader(key, value);
26
- }
27
- }
14
+ copyProxyResponseHeaders(response.headers, res);
28
15
 
29
16
  // Get the response body as buffer and send it
30
17
  const buffer = await response.buffer();
package/src/gdocs.js CHANGED
@@ -42,6 +42,12 @@ const GDOCS_EXPORT_BASE = 'https://docs.google.com/document/d';
42
42
  const GDOCS_API_BASE = 'https://docs.googleapis.com/v1/documents';
43
43
  const DEFAULT_USER_AGENT =
44
44
  'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
45
+ const PUBLIC_EXPORT_HEADERS = {
46
+ 'User-Agent': DEFAULT_USER_AGENT,
47
+ 'Accept-Charset': 'utf-8',
48
+ 'Accept-Language': 'en-US,en;q=0.9',
49
+ 'Accept-Encoding': 'identity',
50
+ };
45
51
 
46
52
  /**
47
53
  * Supported Google Docs export formats.
@@ -165,11 +171,7 @@ export async function fetchGoogleDoc(url, options = {}) {
165
171
  hasApiToken: Boolean(apiToken),
166
172
  }));
167
173
 
168
- const headers = {
169
- 'User-Agent': DEFAULT_USER_AGENT,
170
- 'Accept-Charset': 'utf-8',
171
- 'Accept-Language': 'en-US,en;q=0.9',
172
- };
174
+ const headers = { ...PUBLIC_EXPORT_HEADERS };
173
175
 
174
176
  if (apiToken) {
175
177
  headers.Authorization = `Bearer ${apiToken}`;
package/src/github.js CHANGED
@@ -244,6 +244,7 @@ async function fetchGithubText(url, options = {}) {
244
244
  function githubHeaders(accept) {
245
245
  const headers = {
246
246
  Accept: accept,
247
+ 'Accept-Encoding': 'identity',
247
248
  'User-Agent': GITHUB_USER_AGENT,
248
249
  'X-GitHub-Api-Version': '2022-11-28',
249
250
  };
package/src/index.js CHANGED
@@ -13,6 +13,7 @@ import { animationHandler } from './animation.js';
13
13
  import { figuresHandler } from './figures.js';
14
14
  import { themedImageHandler } from './themed-image.js';
15
15
  import { searchHandler } from './search.js';
16
+ import { sharedDialogHandler } from './shared-dialog.js';
16
17
 
17
18
  const app = express();
18
19
  const port = process.env.PORT || 3000;
@@ -31,6 +32,7 @@ app.get('/animation', animationHandler);
31
32
  app.get('/figures', figuresHandler);
32
33
  app.get('/themed-image', themedImageHandler);
33
34
  app.get('/search', searchHandler);
35
+ app.get('/shared-dialog', sharedDialogHandler);
34
36
 
35
37
  // Start the server if this is the main module
36
38
  const isMainModule =
@@ -78,3 +80,13 @@ export {
78
80
  formatSearchAsMarkdown,
79
81
  SEARCH_PROVIDERS,
80
82
  } from './search.js';
83
+ export {
84
+ captureSharedDialog,
85
+ formatSharedDialogAsDemoMemory,
86
+ formatSharedDialogAsMarkdown,
87
+ formatSharedDialogAsMetaLanguage,
88
+ formatSharedDialogAsText,
89
+ formatSharedDialogResult,
90
+ parseSharedDialog,
91
+ sharedDialogHandler,
92
+ } from './shared-dialog.js';
package/src/lib.js CHANGED
@@ -15,6 +15,9 @@ import { retry } from './retry.js';
15
15
  const STACKPRINTER_RETRIES = 3;
16
16
  const STACKPRINTER_RETRY_BASE_DELAY_MS = 1000;
17
17
  const GOOGLE_DRIVE_FILE_ID_RE = /^[A-Za-z0-9_-]+$/;
18
+ const HTML_FETCH_HEADERS = {
19
+ 'accept-encoding': 'identity',
20
+ };
18
21
 
19
22
  export async function fetchHtml(url) {
20
23
  if (!url) {
@@ -30,7 +33,7 @@ export async function fetchHtml(url) {
30
33
  return await fetchStackPrinterHtml(stackPrinterUrl);
31
34
  }
32
35
 
33
- const response = await fetch(url);
36
+ const response = await fetchHtmlResponse(url);
34
37
  return response.text();
35
38
  }
36
39
 
@@ -194,7 +197,7 @@ function imageExtensionForContentType(contentType) {
194
197
  async function fetchStackPrinterHtml(url) {
195
198
  return await retry(
196
199
  async () => {
197
- const response = await fetch(url);
200
+ const response = await fetchHtmlResponse(url);
198
201
  if (!response.ok) {
199
202
  throw new Error(`StackPrinter returned HTTP ${response.status}`);
200
203
  }
@@ -212,6 +215,12 @@ async function fetchStackPrinterHtml(url) {
212
215
  );
213
216
  }
214
217
 
218
+ function fetchHtmlResponse(url) {
219
+ return fetch(url, {
220
+ headers: HTML_FETCH_HEADERS,
221
+ });
222
+ }
223
+
215
224
  function isStackPrinterTransientError(html) {
216
225
  return /Ooooops|Please try again later/i.test(html);
217
226
  }
@@ -0,0 +1,60 @@
1
+ const HOP_BY_HOP_HEADERS = new Set([
2
+ 'connection',
3
+ 'keep-alive',
4
+ 'proxy-authenticate',
5
+ 'proxy-authorization',
6
+ 'te',
7
+ 'trailer',
8
+ 'transfer-encoding',
9
+ 'upgrade',
10
+ ]);
11
+
12
+ const TRANSFORMED_BODY_HEADERS = new Set(['content-encoding']);
13
+
14
+ export function getProxyHeaderEntries(sourceHeaders, options = {}) {
15
+ const { preserveContentLength = false } = options;
16
+ const connectionTokens = getConnectionTokens(sourceHeaders);
17
+ const isEncoded = Boolean(sourceHeaders.get('content-encoding'));
18
+ const entries = [];
19
+
20
+ for (const [key, value] of sourceHeaders.entries()) {
21
+ const lowerKey = key.toLowerCase();
22
+ if (
23
+ HOP_BY_HOP_HEADERS.has(lowerKey) ||
24
+ TRANSFORMED_BODY_HEADERS.has(lowerKey) ||
25
+ connectionTokens.has(lowerKey)
26
+ ) {
27
+ continue;
28
+ }
29
+ if (
30
+ lowerKey === 'content-length' &&
31
+ (!preserveContentLength || isEncoded)
32
+ ) {
33
+ continue;
34
+ }
35
+ entries.push([key, value]);
36
+ }
37
+
38
+ return entries;
39
+ }
40
+
41
+ export function copyProxyResponseHeaders(sourceHeaders, res, options = {}) {
42
+ const contentType = sourceHeaders.get('content-type') || 'text/plain';
43
+ res.setHeader('Content-Type', contentType);
44
+
45
+ for (const [key, value] of getProxyHeaderEntries(sourceHeaders, options)) {
46
+ if (key.toLowerCase() !== 'content-type') {
47
+ res.setHeader(key, value);
48
+ }
49
+ }
50
+ }
51
+
52
+ function getConnectionTokens(sourceHeaders) {
53
+ const connection = sourceHeaders.get('connection') || '';
54
+ return new Set(
55
+ connection
56
+ .split(',')
57
+ .map((token) => token.trim().toLowerCase())
58
+ .filter(Boolean)
59
+ );
60
+ }
@@ -0,0 +1,25 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ import {
5
+ captureSharedDialog,
6
+ formatSharedDialogResult,
7
+ } from './shared-dialog.js';
8
+
9
+ export async function runSharedDialogCli(sharedDialogUrl, options = {}) {
10
+ const format = (options.format || 'json').toLowerCase();
11
+ const capture = await captureSharedDialog({
12
+ url: sharedDialogUrl,
13
+ capture: options.capture,
14
+ engine: options.engine,
15
+ });
16
+ const body = formatSharedDialogResult(capture, format);
17
+
18
+ if (options.output && options.output !== '-') {
19
+ fs.mkdirSync(path.dirname(options.output), { recursive: true });
20
+ fs.writeFileSync(options.output, body, 'utf-8');
21
+ console.error(`Shared dialog (${format}) saved to: ${options.output}`);
22
+ } else {
23
+ process.stdout.write(body);
24
+ }
25
+ }