@link-assistant/web-capture 1.10.9 → 1.10.11
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 +16 -0
- package/bin/web-capture.js +57 -35
- package/docker-compose.yml +2 -0
- package/package.json +2 -2
- package/src/archive.js +3 -3
- 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/markdown.js +2 -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/package-runtime-dependencies.test.js +26 -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,173 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
import { jest } from '@jest/globals';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
captureSharedDialog,
|
|
9
|
+
formatSharedDialogAsDemoMemory,
|
|
10
|
+
formatSharedDialogAsMarkdown,
|
|
11
|
+
formatSharedDialogAsMetaLanguage,
|
|
12
|
+
parseSharedDialog,
|
|
13
|
+
} from '../../src/shared-dialog.js';
|
|
14
|
+
|
|
15
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
16
|
+
const __dirname = dirname(__filename);
|
|
17
|
+
const repoRoot = resolve(__dirname, '../../..');
|
|
18
|
+
|
|
19
|
+
const CHATGPT_SHARE_URL =
|
|
20
|
+
'https://chatgpt.com/share/6a3825b9-8de4-83ee-9c24-52fd1eb38d24';
|
|
21
|
+
const GOOGLE_AI_MODE_URL = 'https://share.google/aimode/VG0HhpnAXrBkC0QgP';
|
|
22
|
+
|
|
23
|
+
function readCaseStudyFixture(name) {
|
|
24
|
+
return readFileSync(
|
|
25
|
+
resolve(repoRoot, 'docs/case-studies/issue-141/raw-data', name),
|
|
26
|
+
'utf-8'
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('shared-dialog module (#141)', () => {
|
|
31
|
+
it('extracts the four visible ChatGPT shared-dialog turns', () => {
|
|
32
|
+
const capture = parseSharedDialog(
|
|
33
|
+
readCaseStudyFixture('chatgpt-share-6a3825b9.html'),
|
|
34
|
+
{
|
|
35
|
+
sourceUrl: CHATGPT_SHARE_URL,
|
|
36
|
+
captureMethod: 'static_http',
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
expect(capture).toMatchObject({
|
|
41
|
+
provider: 'chatgpt',
|
|
42
|
+
sourceUrl: CHATGPT_SHARE_URL,
|
|
43
|
+
captureMethod: 'static_http',
|
|
44
|
+
status: 'ok',
|
|
45
|
+
conversationId: '6a3825b9-8de4-83ee-9c24-52fd1eb38d24',
|
|
46
|
+
title: 'Infinite loop script',
|
|
47
|
+
});
|
|
48
|
+
expect(capture.turns).toHaveLength(4);
|
|
49
|
+
expect(capture.turns.map((turn) => turn.role)).toEqual([
|
|
50
|
+
'user',
|
|
51
|
+
'assistant',
|
|
52
|
+
'user',
|
|
53
|
+
'assistant',
|
|
54
|
+
]);
|
|
55
|
+
expect(capture.turns[0]).toMatchObject({
|
|
56
|
+
visibility: 'visible',
|
|
57
|
+
sourceEvidence: [
|
|
58
|
+
{
|
|
59
|
+
sourceUrl: CHATGPT_SHARE_URL,
|
|
60
|
+
kind: 'chatgpt_linear_conversation',
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
});
|
|
64
|
+
expect(capture.turns[0].content).toContain('make a loop of that');
|
|
65
|
+
expect(capture.turns[1].content).toContain(
|
|
66
|
+
'while true; do sleep 30m && hive-cleanup -f; done'
|
|
67
|
+
);
|
|
68
|
+
expect(capture.turns[3].content).toContain(
|
|
69
|
+
'screen -dmS auto-cleanup bash -c'
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('formats ChatGPT captures as demo_memory', () => {
|
|
74
|
+
const capture = parseSharedDialog(
|
|
75
|
+
readCaseStudyFixture('chatgpt-share-6a3825b9.html'),
|
|
76
|
+
{
|
|
77
|
+
sourceUrl: CHATGPT_SHARE_URL,
|
|
78
|
+
captureMethod: 'static_http',
|
|
79
|
+
}
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const memory = formatSharedDialogAsDemoMemory(capture, {
|
|
83
|
+
demoLabel: 'issue-552-chatgpt-share',
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(memory).toContain('demo_memory');
|
|
87
|
+
expect(memory.match(/\n {2}event "/g)).toHaveLength(4);
|
|
88
|
+
expect(memory).toContain('role "user"');
|
|
89
|
+
expect(memory).toContain('role "assistant"');
|
|
90
|
+
expect(memory).toContain(
|
|
91
|
+
'conversationId "6a3825b9-8de4-83ee-9c24-52fd1eb38d24"'
|
|
92
|
+
);
|
|
93
|
+
expect(memory).toContain('conversationTitle "Infinite loop script"');
|
|
94
|
+
expect(memory).toContain(`evidence "${CHATGPT_SHARE_URL}"`);
|
|
95
|
+
expect(memory).toContain(
|
|
96
|
+
"screen -dmS auto-cleanup bash -c 'while true; do sleep 30m && hive-cleanup -f; done'"
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('formats captures as shared-dialog meta-language and Markdown', () => {
|
|
101
|
+
const capture = parseSharedDialog(
|
|
102
|
+
readCaseStudyFixture('chatgpt-share-6a3825b9.html'),
|
|
103
|
+
{
|
|
104
|
+
sourceUrl: CHATGPT_SHARE_URL,
|
|
105
|
+
captureMethod: 'static_http',
|
|
106
|
+
}
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const metaLanguage = formatSharedDialogAsMetaLanguage(capture);
|
|
110
|
+
expect(metaLanguage).toContain('shared_dialog_capture');
|
|
111
|
+
expect(metaLanguage).toContain('provider "chatgpt"');
|
|
112
|
+
expect(metaLanguage).toContain(
|
|
113
|
+
'turn "0c9f0151-b5a1-402f-afc3-6bd34a0d01d2"'
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const markdown = formatSharedDialogAsMarkdown(capture);
|
|
117
|
+
expect(markdown).toContain('# Infinite loop script');
|
|
118
|
+
expect(markdown).toContain('**User**');
|
|
119
|
+
expect(markdown).toContain('**Assistant**');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('returns a structured Google AI Mode interstitial diagnostic', () => {
|
|
123
|
+
const capture = parseSharedDialog(
|
|
124
|
+
readCaseStudyFixture('google-ai-mode-VG0HhpnAXrBkC0QgP.html'),
|
|
125
|
+
{
|
|
126
|
+
sourceUrl: GOOGLE_AI_MODE_URL,
|
|
127
|
+
captureMethod: 'static_http',
|
|
128
|
+
}
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
expect(capture).toMatchObject({
|
|
132
|
+
provider: 'google_ai_mode',
|
|
133
|
+
sourceUrl: GOOGLE_AI_MODE_URL,
|
|
134
|
+
captureMethod: 'static_http',
|
|
135
|
+
status: 'unsupported',
|
|
136
|
+
turns: [],
|
|
137
|
+
diagnostics: {
|
|
138
|
+
unsupportedReason: 'provider_challenge_interstitial',
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('uses browser-rendered DOM when static Google AI Mode capture is blocked', async () => {
|
|
144
|
+
const googleHtml = readCaseStudyFixture(
|
|
145
|
+
'google-ai-mode-VG0HhpnAXrBkC0QgP.html'
|
|
146
|
+
);
|
|
147
|
+
const renderHtml = jest.fn(async () => googleHtml);
|
|
148
|
+
const capture = await captureSharedDialog({
|
|
149
|
+
url: GOOGLE_AI_MODE_URL,
|
|
150
|
+
capture: 'browser',
|
|
151
|
+
fetchImpl: async () => ({
|
|
152
|
+
ok: true,
|
|
153
|
+
status: 200,
|
|
154
|
+
text: async () => googleHtml,
|
|
155
|
+
}),
|
|
156
|
+
renderHtml,
|
|
157
|
+
now: () => '2026-06-25T00:00:00.000Z',
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(renderHtml).toHaveBeenCalledWith(GOOGLE_AI_MODE_URL, {
|
|
161
|
+
engine: 'puppeteer',
|
|
162
|
+
});
|
|
163
|
+
expect(capture).toMatchObject({
|
|
164
|
+
provider: 'google_ai_mode',
|
|
165
|
+
captureMethod: 'browser',
|
|
166
|
+
status: 'unsupported',
|
|
167
|
+
capturedAt: '2026-06-25T00:00:00.000Z',
|
|
168
|
+
diagnostics: {
|
|
169
|
+
unsupportedReason: 'provider_challenge_interstitial',
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import http from 'http';
|
|
3
|
+
import request from 'supertest';
|
|
4
|
+
import { streamHandler } from '../../src/stream.js';
|
|
5
|
+
|
|
6
|
+
describe('streamHandler', () => {
|
|
7
|
+
it('streams upstream content with proxy-safe framing', async () => {
|
|
8
|
+
const body = '<!DOCTYPE html><html><body>Example Domain</body></html>';
|
|
9
|
+
let upstreamHeaders;
|
|
10
|
+
const { server, url } = await startUpstreamServer((req, res) => {
|
|
11
|
+
upstreamHeaders = req.headers;
|
|
12
|
+
res.writeHead(200, {
|
|
13
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
14
|
+
'Content-Length': Buffer.byteLength(body),
|
|
15
|
+
});
|
|
16
|
+
res.end(body);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const res = await request(testApp())
|
|
21
|
+
.get('/stream')
|
|
22
|
+
.query({ url })
|
|
23
|
+
.expect(200);
|
|
24
|
+
|
|
25
|
+
expect(upstreamHeaders['accept-encoding']).toBe('identity');
|
|
26
|
+
expect(res.headers['content-type']).toBe('text/html; charset=utf-8');
|
|
27
|
+
expect(res.headers['content-length']).toBe(
|
|
28
|
+
String(Buffer.byteLength(body))
|
|
29
|
+
);
|
|
30
|
+
expect(res.text).toContain('Example Domain');
|
|
31
|
+
} finally {
|
|
32
|
+
await closeServer(server);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('ends the downstream response when upstream closes after body bytes', async () => {
|
|
37
|
+
const body = '<!DOCTYPE html><html><body>Example Domain</body></html>';
|
|
38
|
+
const { server, url } = await startUpstreamServer((req, res) => {
|
|
39
|
+
res.writeHead(200, {
|
|
40
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
41
|
+
});
|
|
42
|
+
res.write(body);
|
|
43
|
+
res.socket.end();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const res = await request(testApp())
|
|
48
|
+
.get('/stream')
|
|
49
|
+
.query({ url })
|
|
50
|
+
.expect(200);
|
|
51
|
+
|
|
52
|
+
expect(res.text).toContain('Example Domain');
|
|
53
|
+
} finally {
|
|
54
|
+
await closeServer(server);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
function testApp() {
|
|
60
|
+
const app = express();
|
|
61
|
+
app.get('/stream', streamHandler);
|
|
62
|
+
return app;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function startUpstreamServer(handler) {
|
|
66
|
+
const server = http.createServer(handler);
|
|
67
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
68
|
+
const { port } = server.address();
|
|
69
|
+
return {
|
|
70
|
+
server,
|
|
71
|
+
url: `http://127.0.0.1:${port}`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function closeServer(server) {
|
|
76
|
+
await new Promise((resolve, reject) => {
|
|
77
|
+
server.close((error) => {
|
|
78
|
+
if (error) {
|
|
79
|
+
reject(error);
|
|
80
|
+
} else {
|
|
81
|
+
resolve();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}
|