@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 +6 -0
- package/README.md +16 -0
- package/bin/web-capture.js +50 -20
- package/docker-compose.yml +2 -0
- package/package.json +1 -1
- package/src/fetch.js +2 -15
- package/src/gdocs.js +7 -5
- package/src/github.js +1 -0
- package/src/index.js +12 -0
- package/src/lib.js +11 -2
- package/src/proxy-headers.js +60 -0
- package/src/shared-dialog-cli.js +25 -0
- package/src/shared-dialog.js +950 -0
- package/src/stream.js +33 -26
- package/tests/e2e/docker.test.js +2 -3
- package/tests/e2e/process.test.js +95 -22
- package/tests/unit/cli.test.js +45 -1
- package/tests/unit/fetch-html.test.js +24 -0
- package/tests/unit/gdocs-export-headers.test.js +28 -0
- package/tests/unit/github.test.js +59 -0
- package/tests/unit/proxy-headers.test.js +100 -0
- package/tests/unit/shared-dialog.test.js +173 -0
- package/tests/unit/stream.test.js +85 -0
|
@@ -0,0 +1,950 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared AI dialog capture and normalization (issue #141).
|
|
3
|
+
*
|
|
4
|
+
* The primary supported source today is ChatGPT shared-page HTML. ChatGPT
|
|
5
|
+
* embeds a React Router stream containing a devalue table; this module decodes
|
|
6
|
+
* that table, finds `linear_conversation`, and keeps visible user/assistant
|
|
7
|
+
* messages. Providers that do not expose transcript data return a structured
|
|
8
|
+
* unsupported diagnostic instead of guessed content.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fetch from 'node-fetch';
|
|
12
|
+
import he from 'he';
|
|
13
|
+
import { URL } from 'node:url';
|
|
14
|
+
|
|
15
|
+
import { createBrowser as defaultCreateBrowser } from './browser.js';
|
|
16
|
+
|
|
17
|
+
const USER_AGENT =
|
|
18
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
|
|
19
|
+
'(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
|
20
|
+
|
|
21
|
+
const ENQUEUE_MARKER = 'window.__reactRouterContext.streamController.enqueue(';
|
|
22
|
+
|
|
23
|
+
export const SHARED_DIALOG_FORMATS = [
|
|
24
|
+
'json',
|
|
25
|
+
'meta-language',
|
|
26
|
+
'demo-memory',
|
|
27
|
+
'markdown',
|
|
28
|
+
'md',
|
|
29
|
+
'txt',
|
|
30
|
+
'text',
|
|
31
|
+
'html',
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
function normalizeSourceUrl(url) {
|
|
35
|
+
if (!url) {
|
|
36
|
+
throw new Error('Missing shared-dialog URL');
|
|
37
|
+
}
|
|
38
|
+
if (url.startsWith('http://') || url.startsWith('https://')) {
|
|
39
|
+
return url;
|
|
40
|
+
}
|
|
41
|
+
return `https://${url}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function providerFromUrl(sourceUrl) {
|
|
45
|
+
try {
|
|
46
|
+
const parsed = new URL(sourceUrl);
|
|
47
|
+
const host = parsed.hostname.toLowerCase();
|
|
48
|
+
if (host === 'chatgpt.com' || host.endsWith('.chatgpt.com')) {
|
|
49
|
+
return 'chatgpt';
|
|
50
|
+
}
|
|
51
|
+
if (host === 'share.google' && parsed.pathname.startsWith('/aimode/')) {
|
|
52
|
+
return 'google_ai_mode';
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// Fall through to body-based detection.
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function detectProvider(input, sourceUrl) {
|
|
61
|
+
const fromUrl = providerFromUrl(sourceUrl);
|
|
62
|
+
if (fromUrl) {
|
|
63
|
+
return fromUrl;
|
|
64
|
+
}
|
|
65
|
+
if (input.includes(ENQUEUE_MARKER) || input.includes('linear_conversation')) {
|
|
66
|
+
return 'chatgpt';
|
|
67
|
+
}
|
|
68
|
+
if (looksLikeGoogleAiModeInterstitial(input)) {
|
|
69
|
+
return 'google_ai_mode';
|
|
70
|
+
}
|
|
71
|
+
return 'unknown';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function okCapture({
|
|
75
|
+
provider,
|
|
76
|
+
sourceUrl,
|
|
77
|
+
captureMethod,
|
|
78
|
+
capturedAt,
|
|
79
|
+
httpStatus,
|
|
80
|
+
title,
|
|
81
|
+
conversationId,
|
|
82
|
+
turns,
|
|
83
|
+
warnings = [],
|
|
84
|
+
}) {
|
|
85
|
+
return {
|
|
86
|
+
provider,
|
|
87
|
+
sourceUrl,
|
|
88
|
+
captureMethod,
|
|
89
|
+
capturedAt,
|
|
90
|
+
status: 'ok',
|
|
91
|
+
conversationId,
|
|
92
|
+
title,
|
|
93
|
+
turns,
|
|
94
|
+
diagnostics: {
|
|
95
|
+
status: 'ok',
|
|
96
|
+
httpStatus,
|
|
97
|
+
warnings,
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function unsupportedCapture({
|
|
103
|
+
provider,
|
|
104
|
+
sourceUrl,
|
|
105
|
+
captureMethod,
|
|
106
|
+
capturedAt,
|
|
107
|
+
httpStatus,
|
|
108
|
+
unsupportedReason,
|
|
109
|
+
message,
|
|
110
|
+
warnings = [],
|
|
111
|
+
error,
|
|
112
|
+
}) {
|
|
113
|
+
return {
|
|
114
|
+
provider,
|
|
115
|
+
sourceUrl,
|
|
116
|
+
captureMethod,
|
|
117
|
+
capturedAt,
|
|
118
|
+
status: 'unsupported',
|
|
119
|
+
conversationId: null,
|
|
120
|
+
title: null,
|
|
121
|
+
turns: [],
|
|
122
|
+
diagnostics: {
|
|
123
|
+
status: 'unsupported',
|
|
124
|
+
httpStatus,
|
|
125
|
+
unsupportedReason,
|
|
126
|
+
message,
|
|
127
|
+
warnings,
|
|
128
|
+
...(error ? { error } : {}),
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseUnsupported(input, provider) {
|
|
134
|
+
if (looksLikeGoogleAiModeInterstitial(input)) {
|
|
135
|
+
return {
|
|
136
|
+
reason: 'provider_challenge_interstitial',
|
|
137
|
+
message:
|
|
138
|
+
'Google AI Mode capture returned a Google Search JavaScript/interstitial page instead of transcript data.',
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
if (/log in|sign in|login required|authentication required/i.test(input)) {
|
|
142
|
+
return {
|
|
143
|
+
reason: 'login_required',
|
|
144
|
+
message:
|
|
145
|
+
'The shared dialog page requires login before transcript data is visible.',
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (/deleted|expired|not found|unavailable/i.test(input)) {
|
|
149
|
+
return {
|
|
150
|
+
reason: 'deleted_or_expired_share',
|
|
151
|
+
message:
|
|
152
|
+
'The shared dialog appears to be deleted, expired, or unavailable.',
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
if (provider === 'unknown') {
|
|
156
|
+
return {
|
|
157
|
+
reason: 'unsupported_provider_format',
|
|
158
|
+
message:
|
|
159
|
+
'The URL or captured DOM is not a supported shared-dialog provider format.',
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
reason: 'no_transcript_in_captured_dom',
|
|
164
|
+
message: 'The captured DOM did not contain replayable transcript data.',
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Parse a captured shared-dialog DOM/transcript into the normalized contract.
|
|
170
|
+
*
|
|
171
|
+
* @param {string} input - Captured HTML or compact transcript
|
|
172
|
+
* @param {Object} options - Parse metadata
|
|
173
|
+
* @returns {Object} Normalized shared-dialog capture
|
|
174
|
+
*/
|
|
175
|
+
export function parseSharedDialog(input, options = {}) {
|
|
176
|
+
const sourceUrl = options.sourceUrl || '';
|
|
177
|
+
const captureMethod = options.captureMethod || 'static_http';
|
|
178
|
+
const capturedAt = options.capturedAt;
|
|
179
|
+
const httpStatus = options.httpStatus;
|
|
180
|
+
const provider = options.provider || detectProvider(input || '', sourceUrl);
|
|
181
|
+
const warnings = [...(options.warnings || [])];
|
|
182
|
+
|
|
183
|
+
if (provider === 'chatgpt') {
|
|
184
|
+
try {
|
|
185
|
+
return parseChatGptShareHtml(input || '', {
|
|
186
|
+
sourceUrl,
|
|
187
|
+
captureMethod,
|
|
188
|
+
capturedAt,
|
|
189
|
+
httpStatus,
|
|
190
|
+
warnings,
|
|
191
|
+
});
|
|
192
|
+
} catch (err) {
|
|
193
|
+
const diagnostic = parseUnsupported(input || '', provider);
|
|
194
|
+
return unsupportedCapture({
|
|
195
|
+
provider,
|
|
196
|
+
sourceUrl,
|
|
197
|
+
captureMethod,
|
|
198
|
+
capturedAt,
|
|
199
|
+
httpStatus,
|
|
200
|
+
unsupportedReason:
|
|
201
|
+
diagnostic.reason === 'no_transcript_in_captured_dom'
|
|
202
|
+
? 'no_transcript_in_captured_dom'
|
|
203
|
+
: diagnostic.reason,
|
|
204
|
+
message:
|
|
205
|
+
diagnostic.reason === 'no_transcript_in_captured_dom'
|
|
206
|
+
? `ChatGPT share capture did not contain a parseable linear_conversation transcript: ${err.message}`
|
|
207
|
+
: diagnostic.message,
|
|
208
|
+
warnings,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const markdown = parseMarkdownTranscript(input || '', {
|
|
214
|
+
provider,
|
|
215
|
+
sourceUrl,
|
|
216
|
+
captureMethod,
|
|
217
|
+
capturedAt,
|
|
218
|
+
httpStatus,
|
|
219
|
+
warnings,
|
|
220
|
+
});
|
|
221
|
+
if (markdown.status === 'ok') {
|
|
222
|
+
return markdown;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const diagnostic = parseUnsupported(input || '', provider);
|
|
226
|
+
return unsupportedCapture({
|
|
227
|
+
provider,
|
|
228
|
+
sourceUrl,
|
|
229
|
+
captureMethod,
|
|
230
|
+
capturedAt,
|
|
231
|
+
httpStatus,
|
|
232
|
+
unsupportedReason: diagnostic.reason,
|
|
233
|
+
message: diagnostic.message,
|
|
234
|
+
warnings,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function parseChatGptShareHtml(input, metadata) {
|
|
239
|
+
const table = extractChatGptDevalueTable(input);
|
|
240
|
+
const resolved = resolveDevalueRoot(table);
|
|
241
|
+
const data = findObjectWithArrayKey(resolved, 'linear_conversation');
|
|
242
|
+
if (!data) {
|
|
243
|
+
throw new Error('linear_conversation data was not found');
|
|
244
|
+
}
|
|
245
|
+
const linear = Array.isArray(data.linear_conversation)
|
|
246
|
+
? data.linear_conversation
|
|
247
|
+
: null;
|
|
248
|
+
if (!linear) {
|
|
249
|
+
throw new Error('linear_conversation field was not an array');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const turns = [];
|
|
253
|
+
linear.forEach((item, index) => {
|
|
254
|
+
const turn = chatGptTurnFromItem(item, index, metadata);
|
|
255
|
+
if (turn) {
|
|
256
|
+
turns.push(turn);
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
if (turns.length === 0) {
|
|
260
|
+
throw new Error('no visible user or assistant turns were found');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const title =
|
|
264
|
+
stringField(data, 'title') ||
|
|
265
|
+
titleFromHtml(input) ||
|
|
266
|
+
metadata.conversationTitle ||
|
|
267
|
+
null;
|
|
268
|
+
const conversationId =
|
|
269
|
+
stringField(data, 'conversation_id') ||
|
|
270
|
+
chatGptShareId(metadata.sourceUrl) ||
|
|
271
|
+
null;
|
|
272
|
+
|
|
273
|
+
return okCapture({
|
|
274
|
+
provider: 'chatgpt',
|
|
275
|
+
sourceUrl: metadata.sourceUrl,
|
|
276
|
+
captureMethod: metadata.captureMethod,
|
|
277
|
+
capturedAt: metadata.capturedAt,
|
|
278
|
+
httpStatus: metadata.httpStatus,
|
|
279
|
+
title,
|
|
280
|
+
conversationId,
|
|
281
|
+
turns,
|
|
282
|
+
warnings: metadata.warnings,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function chatGptTurnFromItem(item, index, metadata) {
|
|
287
|
+
const message = item?.message || item;
|
|
288
|
+
const role = message?.author?.role;
|
|
289
|
+
if (role !== 'user' && role !== 'assistant') {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
if (messageIsHidden(message)) {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
const content = messageContentText(message?.content).trim();
|
|
296
|
+
if (!content) {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
const id = typeof message?.id === 'string' ? message.id : '';
|
|
300
|
+
return {
|
|
301
|
+
id: id || `chatgpt-turn-${index + 1}`,
|
|
302
|
+
role,
|
|
303
|
+
content,
|
|
304
|
+
visibility: 'visible',
|
|
305
|
+
sourceEvidence: [
|
|
306
|
+
{
|
|
307
|
+
kind: 'chatgpt_linear_conversation',
|
|
308
|
+
sourceUrl: metadata.sourceUrl,
|
|
309
|
+
captureMethod: metadata.captureMethod,
|
|
310
|
+
pointer: `linear_conversation[${index}].message`,
|
|
311
|
+
},
|
|
312
|
+
],
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function messageIsHidden(message) {
|
|
317
|
+
return Boolean(message?.metadata?.is_visually_hidden_from_conversation);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function messageContentText(content) {
|
|
321
|
+
if (!content) {
|
|
322
|
+
return '';
|
|
323
|
+
}
|
|
324
|
+
if (typeof content === 'string') {
|
|
325
|
+
return content;
|
|
326
|
+
}
|
|
327
|
+
if (Array.isArray(content.parts)) {
|
|
328
|
+
const parts = [];
|
|
329
|
+
for (const part of content.parts) {
|
|
330
|
+
if (typeof part === 'string' && part) {
|
|
331
|
+
parts.push(part);
|
|
332
|
+
} else if (typeof part?.text === 'string' && part.text) {
|
|
333
|
+
parts.push(part.text);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return parts.join('\n\n');
|
|
337
|
+
}
|
|
338
|
+
return typeof content.text === 'string' ? content.text : '';
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function extractChatGptDevalueTable(input) {
|
|
342
|
+
for (const chunk of extractReactRouterStreamChunks(input)) {
|
|
343
|
+
const trimmed = chunk.trimStart();
|
|
344
|
+
if (!trimmed.startsWith('[')) {
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
const value = JSON.parse(trimmed);
|
|
348
|
+
if (Array.isArray(value)) {
|
|
349
|
+
return value;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
throw new Error('ChatGPT share capture did not contain a JSON devalue table');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function extractReactRouterStreamChunks(input) {
|
|
356
|
+
const chunks = [];
|
|
357
|
+
let cursor = 0;
|
|
358
|
+
while (cursor < input.length) {
|
|
359
|
+
const relative = input.indexOf(ENQUEUE_MARKER, cursor);
|
|
360
|
+
if (relative === -1) {
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
let literalStart = relative + ENQUEUE_MARKER.length;
|
|
364
|
+
while (/[\s]/.test(input[literalStart] || '')) {
|
|
365
|
+
literalStart += 1;
|
|
366
|
+
}
|
|
367
|
+
if (input[literalStart] !== '"') {
|
|
368
|
+
cursor = literalStart + 1;
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
const { literal, length } = extractJsonStringLiteral(
|
|
372
|
+
input.slice(literalStart)
|
|
373
|
+
);
|
|
374
|
+
chunks.push(JSON.parse(literal));
|
|
375
|
+
cursor = literalStart + length;
|
|
376
|
+
}
|
|
377
|
+
if (chunks.length === 0) {
|
|
378
|
+
throw new Error(
|
|
379
|
+
'ChatGPT share capture did not contain React Router chunks'
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
return chunks;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function extractJsonStringLiteral(input) {
|
|
386
|
+
if (input[0] !== '"') {
|
|
387
|
+
throw new Error('expected a JSON string literal');
|
|
388
|
+
}
|
|
389
|
+
let index = 1;
|
|
390
|
+
while (index < input.length) {
|
|
391
|
+
if (input[index] === '\\') {
|
|
392
|
+
index += 2;
|
|
393
|
+
} else if (input[index] === '"') {
|
|
394
|
+
return {
|
|
395
|
+
literal: input.slice(0, index + 1),
|
|
396
|
+
length: index + 1,
|
|
397
|
+
};
|
|
398
|
+
} else {
|
|
399
|
+
index += 1;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
throw new Error('unterminated JSON string literal');
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function resolveDevalueRoot(table) {
|
|
406
|
+
if (!table.length) {
|
|
407
|
+
throw new Error('ChatGPT devalue table was empty');
|
|
408
|
+
}
|
|
409
|
+
return resolveTableIndex(table, 0, []);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function resolveTableIndex(table, index, stack) {
|
|
413
|
+
if (index >= table.length || stack.includes(index)) {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
stack.push(index);
|
|
417
|
+
const value = resolveTableValue(table, table[index], stack);
|
|
418
|
+
stack.pop();
|
|
419
|
+
return value;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function resolveTableValue(table, value, stack) {
|
|
423
|
+
if (Array.isArray(value)) {
|
|
424
|
+
return value.map((item) => resolveEncodedReference(table, item, stack));
|
|
425
|
+
}
|
|
426
|
+
if (value && typeof value === 'object') {
|
|
427
|
+
const resolved = {};
|
|
428
|
+
for (const [encodedKey, encodedValue] of Object.entries(value)) {
|
|
429
|
+
const key = resolveObjectKey(table, encodedKey, stack);
|
|
430
|
+
resolved[key] = resolveEncodedReference(table, encodedValue, stack);
|
|
431
|
+
}
|
|
432
|
+
return resolved;
|
|
433
|
+
}
|
|
434
|
+
return value;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function resolveEncodedReference(table, value, stack) {
|
|
438
|
+
if (Number.isInteger(value)) {
|
|
439
|
+
if (value < 0) {
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
return resolveTableIndex(table, value, stack);
|
|
443
|
+
}
|
|
444
|
+
return resolveTableValue(table, value, stack);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function resolveObjectKey(table, encodedKey, stack) {
|
|
448
|
+
if (encodedKey.startsWith('_')) {
|
|
449
|
+
const index = Number.parseInt(encodedKey.slice(1), 10);
|
|
450
|
+
if (Number.isInteger(index)) {
|
|
451
|
+
const key = resolveTableIndex(table, index, stack);
|
|
452
|
+
if (typeof key === 'string') {
|
|
453
|
+
return key;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return encodedKey;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function findObjectWithArrayKey(value, key) {
|
|
461
|
+
if (Array.isArray(value)) {
|
|
462
|
+
for (const item of value) {
|
|
463
|
+
const found = findObjectWithArrayKey(item, key);
|
|
464
|
+
if (found) {
|
|
465
|
+
return found;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
if (value && typeof value === 'object') {
|
|
471
|
+
if (Array.isArray(value[key])) {
|
|
472
|
+
return value;
|
|
473
|
+
}
|
|
474
|
+
for (const child of Object.values(value)) {
|
|
475
|
+
const found = findObjectWithArrayKey(child, key);
|
|
476
|
+
if (found) {
|
|
477
|
+
return found;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function parseMarkdownTranscript(input, metadata) {
|
|
485
|
+
const turns = [];
|
|
486
|
+
let currentRole = null;
|
|
487
|
+
let currentLines = [];
|
|
488
|
+
|
|
489
|
+
for (const line of input.split(/\r?\n/)) {
|
|
490
|
+
const prefixed = markdownTurnPrefix(line);
|
|
491
|
+
if (prefixed) {
|
|
492
|
+
pushMarkdownTurn(turns, currentRole, currentLines, metadata);
|
|
493
|
+
currentRole = prefixed.role;
|
|
494
|
+
currentLines = [prefixed.rest.trimStart()];
|
|
495
|
+
} else if (currentRole) {
|
|
496
|
+
currentLines.push(line);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
pushMarkdownTurn(turns, currentRole, currentLines, metadata);
|
|
500
|
+
|
|
501
|
+
if (turns.length === 0) {
|
|
502
|
+
return unsupportedCapture({
|
|
503
|
+
provider: metadata.provider,
|
|
504
|
+
sourceUrl: metadata.sourceUrl,
|
|
505
|
+
captureMethod: metadata.captureMethod,
|
|
506
|
+
capturedAt: metadata.capturedAt,
|
|
507
|
+
httpStatus: metadata.httpStatus,
|
|
508
|
+
unsupportedReason: 'no_transcript_in_captured_dom',
|
|
509
|
+
message: 'No compact Markdown transcript turns were found.',
|
|
510
|
+
warnings: metadata.warnings,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return okCapture({
|
|
515
|
+
provider: metadata.provider,
|
|
516
|
+
sourceUrl: metadata.sourceUrl,
|
|
517
|
+
captureMethod: metadata.captureMethod,
|
|
518
|
+
capturedAt: metadata.capturedAt,
|
|
519
|
+
httpStatus: metadata.httpStatus,
|
|
520
|
+
title: null,
|
|
521
|
+
conversationId: null,
|
|
522
|
+
turns,
|
|
523
|
+
warnings: metadata.warnings,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function markdownTurnPrefix(line) {
|
|
528
|
+
const trimmed = line.trimStart();
|
|
529
|
+
for (const [prefix, role] of [
|
|
530
|
+
['U:', 'user'],
|
|
531
|
+
['User:', 'user'],
|
|
532
|
+
['A:', 'assistant'],
|
|
533
|
+
['Assistant:', 'assistant'],
|
|
534
|
+
]) {
|
|
535
|
+
if (
|
|
536
|
+
trimmed.slice(0, prefix.length).toLowerCase() === prefix.toLowerCase()
|
|
537
|
+
) {
|
|
538
|
+
return { role, rest: trimmed.slice(prefix.length) };
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function pushMarkdownTurn(turns, role, lines, metadata) {
|
|
545
|
+
if (!role) {
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
const content = trimmedContentLines(lines);
|
|
549
|
+
if (!content) {
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
turns.push({
|
|
553
|
+
id: `markdown-turn-${turns.length + 1}`,
|
|
554
|
+
role,
|
|
555
|
+
content,
|
|
556
|
+
visibility: 'visible',
|
|
557
|
+
sourceEvidence: [
|
|
558
|
+
{
|
|
559
|
+
kind: 'markdown_transcript',
|
|
560
|
+
sourceUrl: metadata.sourceUrl,
|
|
561
|
+
captureMethod: metadata.captureMethod,
|
|
562
|
+
pointer: `turns[${turns.length}]`,
|
|
563
|
+
},
|
|
564
|
+
],
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function trimmedContentLines(lines) {
|
|
569
|
+
const start = lines.findIndex((line) => line.trim());
|
|
570
|
+
if (start === -1) {
|
|
571
|
+
return '';
|
|
572
|
+
}
|
|
573
|
+
let end = lines.length - 1;
|
|
574
|
+
while (end > start && !lines[end].trim()) {
|
|
575
|
+
end -= 1;
|
|
576
|
+
}
|
|
577
|
+
return lines
|
|
578
|
+
.slice(start, end + 1)
|
|
579
|
+
.join('\n')
|
|
580
|
+
.trim();
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function stringField(object, key) {
|
|
584
|
+
return typeof object?.[key] === 'string' ? object[key] : null;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function titleFromHtml(input) {
|
|
588
|
+
const match = input.match(/<title>([\s\S]*?)<\/title>/i);
|
|
589
|
+
if (!match) {
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
const title = he.decode(match[1]).trim();
|
|
593
|
+
return title.replace(/^ChatGPT\s*-\s*/i, '') || null;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function chatGptShareId(sourceUrl) {
|
|
597
|
+
if (!sourceUrl) {
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
600
|
+
try {
|
|
601
|
+
const parsed = new URL(sourceUrl);
|
|
602
|
+
const parts = parsed.pathname.split('/').filter(Boolean);
|
|
603
|
+
const shareIndex = parts.indexOf('share');
|
|
604
|
+
if (shareIndex !== -1 && parts[shareIndex + 1]) {
|
|
605
|
+
return parts[shareIndex + 1];
|
|
606
|
+
}
|
|
607
|
+
} catch {
|
|
608
|
+
return null;
|
|
609
|
+
}
|
|
610
|
+
return null;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function looksLikeGoogleAiModeInterstitial(input) {
|
|
614
|
+
return (
|
|
615
|
+
input.includes('share.google/aimode') ||
|
|
616
|
+
(input.includes('Google Search') &&
|
|
617
|
+
input.includes("If you're having trouble accessing Google Search")) ||
|
|
618
|
+
(input.includes('/search?q=') && input.includes('enablejs')) ||
|
|
619
|
+
(input.includes('/httpservice/retry/enablejs') &&
|
|
620
|
+
input.includes('Google Search'))
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Render a URL through the repository browser abstraction and return DOM HTML.
|
|
626
|
+
*
|
|
627
|
+
* @param {string} url - Absolute URL
|
|
628
|
+
* @param {Object} options - Browser options
|
|
629
|
+
* @returns {Promise<string>} Rendered DOM
|
|
630
|
+
*/
|
|
631
|
+
export async function renderSharedDialogWithBrowser(url, options = {}) {
|
|
632
|
+
const browser = await (options.createBrowser || defaultCreateBrowser)(
|
|
633
|
+
options.engine || 'puppeteer'
|
|
634
|
+
);
|
|
635
|
+
try {
|
|
636
|
+
const page = await browser.newPage();
|
|
637
|
+
await page.setExtraHTTPHeaders({
|
|
638
|
+
'Accept-Language': 'en-US,en;q=0.9',
|
|
639
|
+
'Accept-Charset': 'utf-8',
|
|
640
|
+
});
|
|
641
|
+
await page.setUserAgent(USER_AGENT);
|
|
642
|
+
await page.setViewport({ width: 1280, height: 800 });
|
|
643
|
+
await page.goto(url, { waitUntil: 'networkidle0', timeout: 30000 });
|
|
644
|
+
await page.waitForTimeout?.(5000);
|
|
645
|
+
return await page.content();
|
|
646
|
+
} finally {
|
|
647
|
+
await browser.close();
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Fetch and normalize a shared dialog URL. Browser mode first tries static HTTP
|
|
653
|
+
* because some providers expose all transcript data there, then falls back to
|
|
654
|
+
* rendered DOM when the static capture is unsupported.
|
|
655
|
+
*
|
|
656
|
+
* @param {Object} options - Capture options
|
|
657
|
+
* @returns {Promise<Object>} Normalized shared-dialog capture
|
|
658
|
+
*/
|
|
659
|
+
export async function captureSharedDialog({
|
|
660
|
+
url,
|
|
661
|
+
capture = 'browser',
|
|
662
|
+
fetchImpl = fetch,
|
|
663
|
+
renderHtml = renderSharedDialogWithBrowser,
|
|
664
|
+
engine = 'puppeteer',
|
|
665
|
+
now = () => new Date().toISOString(),
|
|
666
|
+
} = {}) {
|
|
667
|
+
const sourceUrl = normalizeSourceUrl(url);
|
|
668
|
+
new URL(sourceUrl);
|
|
669
|
+
const capturedAt = now();
|
|
670
|
+
let staticHtml = '';
|
|
671
|
+
let httpStatus = 0;
|
|
672
|
+
let warnings = [];
|
|
673
|
+
|
|
674
|
+
try {
|
|
675
|
+
const response = await fetchImpl(sourceUrl, {
|
|
676
|
+
headers: {
|
|
677
|
+
'User-Agent': USER_AGENT,
|
|
678
|
+
Accept: 'text/html,application/xhtml+xml',
|
|
679
|
+
'Accept-Language': 'en-US,en;q=0.9',
|
|
680
|
+
},
|
|
681
|
+
});
|
|
682
|
+
httpStatus = response.status || 0;
|
|
683
|
+
staticHtml = await response.text();
|
|
684
|
+
} catch (err) {
|
|
685
|
+
warnings = [`static_http_failed: ${err.message}`];
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const staticCapture = parseSharedDialog(staticHtml, {
|
|
689
|
+
sourceUrl,
|
|
690
|
+
captureMethod: 'static_http',
|
|
691
|
+
capturedAt,
|
|
692
|
+
httpStatus,
|
|
693
|
+
warnings,
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
if (staticCapture.status === 'ok' || capture.toLowerCase() === 'api') {
|
|
697
|
+
return staticCapture;
|
|
698
|
+
}
|
|
699
|
+
if (capture.toLowerCase() !== 'browser') {
|
|
700
|
+
return withWarning(
|
|
701
|
+
staticCapture,
|
|
702
|
+
`unsupported_capture_method: ${capture}; use browser or api`
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
try {
|
|
707
|
+
const renderedHtml = await renderHtml(sourceUrl, { engine });
|
|
708
|
+
return parseSharedDialog(renderedHtml, {
|
|
709
|
+
sourceUrl,
|
|
710
|
+
captureMethod: 'browser',
|
|
711
|
+
capturedAt,
|
|
712
|
+
warnings:
|
|
713
|
+
staticCapture.status === 'unsupported'
|
|
714
|
+
? [
|
|
715
|
+
...staticCapture.diagnostics.warnings,
|
|
716
|
+
`static_http_unsupported: ${staticCapture.diagnostics.unsupportedReason}`,
|
|
717
|
+
]
|
|
718
|
+
: staticCapture.diagnostics.warnings,
|
|
719
|
+
});
|
|
720
|
+
} catch (err) {
|
|
721
|
+
return withWarning(staticCapture, `browser_render_failed: ${err.message}`);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function withWarning(capture, warning) {
|
|
726
|
+
return {
|
|
727
|
+
...capture,
|
|
728
|
+
diagnostics: {
|
|
729
|
+
...capture.diagnostics,
|
|
730
|
+
warnings: [...(capture.diagnostics.warnings || []), warning],
|
|
731
|
+
},
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function escapeLino(value) {
|
|
736
|
+
return String(value ?? '')
|
|
737
|
+
.replace(/\\/g, '\\\\')
|
|
738
|
+
.replace(/"/g, '\\"')
|
|
739
|
+
.replace(/\n/g, '\\n')
|
|
740
|
+
.replace(/\r/g, '\\r')
|
|
741
|
+
.replace(/\t/g, '\\t');
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function pushField(lines, indent, name, value) {
|
|
745
|
+
if (value === undefined || value === null || value === '') {
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
lines.push(`${indent}${name} "${escapeLino(value)}"`);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
export function formatSharedDialogAsMetaLanguage(capture) {
|
|
752
|
+
const lines = ['shared_dialog_capture'];
|
|
753
|
+
pushField(lines, ' ', 'provider', capture.provider);
|
|
754
|
+
pushField(lines, ' ', 'sourceUrl', capture.sourceUrl);
|
|
755
|
+
pushField(lines, ' ', 'captureMethod', capture.captureMethod);
|
|
756
|
+
pushField(lines, ' ', 'capturedAt', capture.capturedAt);
|
|
757
|
+
pushField(lines, ' ', 'status', capture.status);
|
|
758
|
+
pushField(lines, ' ', 'conversationId', capture.conversationId);
|
|
759
|
+
pushField(lines, ' ', 'title', capture.title);
|
|
760
|
+
lines.push(' diagnostics');
|
|
761
|
+
pushField(lines, ' ', 'status', capture.diagnostics.status);
|
|
762
|
+
pushField(lines, ' ', 'httpStatus', capture.diagnostics.httpStatus);
|
|
763
|
+
pushField(
|
|
764
|
+
lines,
|
|
765
|
+
' ',
|
|
766
|
+
'unsupportedReason',
|
|
767
|
+
capture.diagnostics.unsupportedReason
|
|
768
|
+
);
|
|
769
|
+
pushField(lines, ' ', 'message', capture.diagnostics.message);
|
|
770
|
+
for (const warning of capture.diagnostics.warnings || []) {
|
|
771
|
+
pushField(lines, ' ', 'warning', warning);
|
|
772
|
+
}
|
|
773
|
+
for (const turn of capture.turns || []) {
|
|
774
|
+
lines.push(` turn "${escapeLino(turn.id)}"`);
|
|
775
|
+
pushField(lines, ' ', 'role', turn.role);
|
|
776
|
+
pushField(lines, ' ', 'visibility', turn.visibility);
|
|
777
|
+
pushField(lines, ' ', 'content', turn.content);
|
|
778
|
+
for (const evidence of turn.sourceEvidence || []) {
|
|
779
|
+
lines.push(' evidence');
|
|
780
|
+
pushField(lines, ' ', 'kind', evidence.kind);
|
|
781
|
+
pushField(lines, ' ', 'sourceUrl', evidence.sourceUrl);
|
|
782
|
+
pushField(lines, ' ', 'captureMethod', evidence.captureMethod);
|
|
783
|
+
pushField(lines, ' ', 'pointer', evidence.pointer);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
return `${lines.join('\n')}\n`;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
export function formatSharedDialogAsDemoMemory(
|
|
790
|
+
capture,
|
|
791
|
+
{ demoLabel = 'web-capture-shared-dialog' } = {}
|
|
792
|
+
) {
|
|
793
|
+
const lines = ['demo_memory'];
|
|
794
|
+
if (capture.status !== 'ok') {
|
|
795
|
+
lines.push(' diagnostic "shared-dialog-unsupported"');
|
|
796
|
+
pushField(lines, ' ', 'provider', capture.provider);
|
|
797
|
+
pushField(lines, ' ', 'sourceUrl', capture.sourceUrl);
|
|
798
|
+
pushField(lines, ' ', 'captureMethod', capture.captureMethod);
|
|
799
|
+
pushField(
|
|
800
|
+
lines,
|
|
801
|
+
' ',
|
|
802
|
+
'unsupportedReason',
|
|
803
|
+
capture.diagnostics.unsupportedReason
|
|
804
|
+
);
|
|
805
|
+
pushField(lines, ' ', 'message', capture.diagnostics.message);
|
|
806
|
+
return `${lines.join('\n')}\n`;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
for (const turn of capture.turns) {
|
|
810
|
+
lines.push(` event "${escapeLino(turn.id)}"`);
|
|
811
|
+
pushField(lines, ' ', 'role', turn.role);
|
|
812
|
+
pushField(lines, ' ', 'content', turn.content);
|
|
813
|
+
pushField(lines, ' ', 'demoLabel', demoLabel);
|
|
814
|
+
pushField(lines, ' ', 'conversationId', capture.conversationId);
|
|
815
|
+
pushField(lines, ' ', 'conversationTitle', capture.title);
|
|
816
|
+
for (const evidence of turn.sourceEvidence || []) {
|
|
817
|
+
pushField(lines, ' ', 'evidence', evidence.sourceUrl);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
return `${lines.join('\n')}\n`;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
export function formatSharedDialogAsMarkdown(capture) {
|
|
824
|
+
const lines = [];
|
|
825
|
+
lines.push(`# ${capture.title || 'Shared Dialog Capture'}`);
|
|
826
|
+
lines.push('');
|
|
827
|
+
lines.push(`- Provider: \`${capture.provider}\``);
|
|
828
|
+
lines.push(`- Source: ${capture.sourceUrl || ''}`);
|
|
829
|
+
lines.push(`- Capture method: \`${capture.captureMethod}\``);
|
|
830
|
+
lines.push(`- Status: \`${capture.status}\``);
|
|
831
|
+
if (capture.conversationId) {
|
|
832
|
+
lines.push(`- Conversation ID: \`${capture.conversationId}\``);
|
|
833
|
+
}
|
|
834
|
+
if (capture.status !== 'ok') {
|
|
835
|
+
lines.push(
|
|
836
|
+
`- Unsupported reason: \`${capture.diagnostics.unsupportedReason}\``
|
|
837
|
+
);
|
|
838
|
+
if (capture.diagnostics.message) {
|
|
839
|
+
lines.push('');
|
|
840
|
+
lines.push(capture.diagnostics.message);
|
|
841
|
+
}
|
|
842
|
+
return `${lines.join('\n')}\n`;
|
|
843
|
+
}
|
|
844
|
+
lines.push('');
|
|
845
|
+
for (const turn of capture.turns) {
|
|
846
|
+
lines.push(`**${turn.role === 'user' ? 'User' : 'Assistant'}**`);
|
|
847
|
+
lines.push('');
|
|
848
|
+
lines.push(turn.content);
|
|
849
|
+
lines.push('');
|
|
850
|
+
}
|
|
851
|
+
return `${lines.join('\n').trimEnd()}\n`;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
export function formatSharedDialogAsText(capture) {
|
|
855
|
+
if (capture.status !== 'ok') {
|
|
856
|
+
return [
|
|
857
|
+
`Provider: ${capture.provider}`,
|
|
858
|
+
`Source: ${capture.sourceUrl || ''}`,
|
|
859
|
+
`Capture method: ${capture.captureMethod}`,
|
|
860
|
+
`Status: ${capture.status}`,
|
|
861
|
+
`Unsupported reason: ${capture.diagnostics.unsupportedReason}`,
|
|
862
|
+
capture.diagnostics.message || '',
|
|
863
|
+
'',
|
|
864
|
+
].join('\n');
|
|
865
|
+
}
|
|
866
|
+
const lines = [];
|
|
867
|
+
for (const turn of capture.turns) {
|
|
868
|
+
lines.push(`${turn.role === 'user' ? 'User' : 'Assistant'}:`);
|
|
869
|
+
lines.push(turn.content);
|
|
870
|
+
lines.push('');
|
|
871
|
+
}
|
|
872
|
+
return lines.join('\n');
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
export function formatSharedDialogAsHtml(capture) {
|
|
876
|
+
const markdown = formatSharedDialogAsMarkdown(capture);
|
|
877
|
+
return `<!doctype html>
|
|
878
|
+
<html>
|
|
879
|
+
<head><meta charset="utf-8"><title>${he.escape(capture.title || 'Shared Dialog Capture')}</title></head>
|
|
880
|
+
<body><pre>${he.escape(markdown)}</pre></body>
|
|
881
|
+
</html>
|
|
882
|
+
`;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
export function formatSharedDialogResult(capture, format = 'json') {
|
|
886
|
+
const normalized = format.toLowerCase();
|
|
887
|
+
if (normalized === 'meta-language' || normalized === 'meta_language') {
|
|
888
|
+
return formatSharedDialogAsMetaLanguage(capture);
|
|
889
|
+
}
|
|
890
|
+
if (normalized === 'demo-memory' || normalized === 'demo_memory') {
|
|
891
|
+
return formatSharedDialogAsDemoMemory(capture);
|
|
892
|
+
}
|
|
893
|
+
if (normalized === 'markdown' || normalized === 'md') {
|
|
894
|
+
return formatSharedDialogAsMarkdown(capture);
|
|
895
|
+
}
|
|
896
|
+
if (normalized === 'txt' || normalized === 'text') {
|
|
897
|
+
return formatSharedDialogAsText(capture);
|
|
898
|
+
}
|
|
899
|
+
if (normalized === 'html') {
|
|
900
|
+
return formatSharedDialogAsHtml(capture);
|
|
901
|
+
}
|
|
902
|
+
return `${JSON.stringify(capture, null, 2)}\n`;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
export function sharedDialogOutputExtension(format = 'json') {
|
|
906
|
+
const normalized = format.toLowerCase();
|
|
907
|
+
if (normalized === 'meta-language' || normalized === 'meta_language') {
|
|
908
|
+
return 'lino';
|
|
909
|
+
}
|
|
910
|
+
if (normalized === 'demo-memory' || normalized === 'demo_memory') {
|
|
911
|
+
return 'lino';
|
|
912
|
+
}
|
|
913
|
+
if (normalized === 'markdown' || normalized === 'md') {
|
|
914
|
+
return 'md';
|
|
915
|
+
}
|
|
916
|
+
if (normalized === 'txt' || normalized === 'text') {
|
|
917
|
+
return 'txt';
|
|
918
|
+
}
|
|
919
|
+
if (normalized === 'html') {
|
|
920
|
+
return 'html';
|
|
921
|
+
}
|
|
922
|
+
return 'json';
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
export async function sharedDialogHandler(req, res) {
|
|
926
|
+
const url = req.query.url;
|
|
927
|
+
if (!url) {
|
|
928
|
+
return res.status(400).send('Missing `url` parameter');
|
|
929
|
+
}
|
|
930
|
+
const format = (req.query.format || 'json').toLowerCase();
|
|
931
|
+
try {
|
|
932
|
+
const capture = await captureSharedDialog({
|
|
933
|
+
url,
|
|
934
|
+
capture: req.query.capture || 'browser',
|
|
935
|
+
engine: req.query.engine || req.query.browser || 'puppeteer',
|
|
936
|
+
});
|
|
937
|
+
const body = formatSharedDialogResult(capture, format);
|
|
938
|
+
if (format === 'json') {
|
|
939
|
+
res.type('application/json').send(body);
|
|
940
|
+
} else if (format === 'markdown' || format === 'md') {
|
|
941
|
+
res.type('text/markdown').send(body);
|
|
942
|
+
} else if (format === 'html') {
|
|
943
|
+
res.type('text/html').send(body);
|
|
944
|
+
} else {
|
|
945
|
+
res.type('text/plain').send(body);
|
|
946
|
+
}
|
|
947
|
+
} catch (err) {
|
|
948
|
+
res.status(500).send(`Error capturing shared dialog: ${err.message}`);
|
|
949
|
+
}
|
|
950
|
+
}
|