@librechat/agents 3.2.63 → 3.2.65
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/dist/cjs/graphs/Graph.cjs +3 -0
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/index.cjs +73 -9
- package/dist/cjs/llm/anthropic/index.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/types.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +41 -9
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/utils/message_outputs.cjs +3 -1
- package/dist/cjs/llm/anthropic/utils/message_outputs.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/utils/stream_events.cjs +337 -0
- package/dist/cjs/llm/anthropic/utils/stream_events.cjs.map +1 -0
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +82 -34
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/tools/search/crw-scraper.cjs +165 -0
- package/dist/cjs/tools/search/crw-scraper.cjs.map +1 -0
- package/dist/cjs/tools/search/crw-search.cjs +105 -0
- package/dist/cjs/tools/search/crw-search.cjs.map +1 -0
- package/dist/cjs/tools/search/search.cjs +4 -2
- package/dist/cjs/tools/search/search.cjs.map +1 -1
- package/dist/cjs/tools/search/tool.cjs +15 -3
- package/dist/cjs/tools/search/tool.cjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +3 -0
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/llm/anthropic/index.mjs +73 -9
- package/dist/esm/llm/anthropic/index.mjs.map +1 -1
- package/dist/esm/llm/anthropic/types.mjs.map +1 -1
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs +41 -9
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/anthropic/utils/message_outputs.mjs +3 -2
- package/dist/esm/llm/anthropic/utils/message_outputs.mjs.map +1 -1
- package/dist/esm/llm/anthropic/utils/stream_events.mjs +337 -0
- package/dist/esm/llm/anthropic/utils/stream_events.mjs.map +1 -0
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs +82 -34
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/tools/search/crw-scraper.mjs +163 -0
- package/dist/esm/tools/search/crw-scraper.mjs.map +1 -0
- package/dist/esm/tools/search/crw-search.mjs +103 -0
- package/dist/esm/tools/search/crw-search.mjs.map +1 -0
- package/dist/esm/tools/search/search.mjs +4 -2
- package/dist/esm/tools/search/search.mjs.map +1 -1
- package/dist/esm/tools/search/tool.mjs +15 -3
- package/dist/esm/tools/search/tool.mjs.map +1 -1
- package/dist/types/llm/anthropic/index.d.ts +2 -0
- package/dist/types/llm/anthropic/types.d.ts +2 -0
- package/dist/types/llm/anthropic/utils/message_outputs.d.ts +1 -2
- package/dist/types/llm/anthropic/utils/stream_events.d.ts +25 -0
- package/dist/types/tools/search/crw-scraper.d.ts +41 -0
- package/dist/types/tools/search/crw-search.d.ts +4 -0
- package/dist/types/tools/search/types.d.ts +88 -3
- package/package.json +1 -1
- package/src/graphs/Graph.ts +15 -0
- package/src/llm/anthropic/index.ts +134 -10
- package/src/llm/anthropic/inherited-content-utils.spec.ts +10 -5
- package/src/llm/anthropic/inherited-stream-events.spec.ts +513 -9
- package/src/llm/anthropic/llm.spec.ts +100 -16
- package/src/llm/anthropic/types.ts +3 -0
- package/src/llm/anthropic/utils/message_inputs.ts +60 -3
- package/src/llm/anthropic/utils/message_outputs.ts +10 -2
- package/src/llm/anthropic/utils/stream_events.ts +471 -0
- package/src/llm/bedrock/utils/message_inputs.test.ts +276 -1
- package/src/llm/bedrock/utils/message_inputs.ts +121 -73
- package/src/tools/search/crw-scraper.ts +244 -0
- package/src/tools/search/crw-search.ts +167 -0
- package/src/tools/search/crw.test.ts +836 -0
- package/src/tools/search/search.ts +7 -1
- package/src/tools/search/tool.ts +23 -3
- package/src/tools/search/types.ts +103 -3
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import type * as t from './types';
|
|
3
|
+
import { CrwScraper, createCrwScraper } from './crw-scraper';
|
|
4
|
+
import { createSearchAPI } from './search';
|
|
5
|
+
import { createSearchTool } from './tool';
|
|
6
|
+
import { DATE_RANGE } from './schema';
|
|
7
|
+
|
|
8
|
+
jest.mock('axios');
|
|
9
|
+
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
|
10
|
+
|
|
11
|
+
const mockLogger = {
|
|
12
|
+
error: jest.fn(),
|
|
13
|
+
warn: jest.fn(),
|
|
14
|
+
info: jest.fn(),
|
|
15
|
+
debug: jest.fn(),
|
|
16
|
+
} as unknown as t.Logger;
|
|
17
|
+
|
|
18
|
+
describe('CRW search API', () => {
|
|
19
|
+
const originalKey = process.env.CRW_API_KEY;
|
|
20
|
+
const originalUrl = process.env.CRW_API_URL;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
jest.clearAllMocks();
|
|
24
|
+
delete process.env.CRW_API_KEY;
|
|
25
|
+
delete process.env.CRW_API_URL;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterAll(() => {
|
|
29
|
+
if (originalKey === undefined) {
|
|
30
|
+
delete process.env.CRW_API_KEY;
|
|
31
|
+
} else {
|
|
32
|
+
process.env.CRW_API_KEY = originalKey;
|
|
33
|
+
}
|
|
34
|
+
if (originalUrl === undefined) {
|
|
35
|
+
delete process.env.CRW_API_URL;
|
|
36
|
+
} else {
|
|
37
|
+
process.env.CRW_API_URL = originalUrl;
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('does not throw when the CRW API key is missing', () => {
|
|
42
|
+
expect(() =>
|
|
43
|
+
createSearchAPI({
|
|
44
|
+
searchProvider: 'crw',
|
|
45
|
+
crwApiKey: '',
|
|
46
|
+
})
|
|
47
|
+
).not.toThrow();
|
|
48
|
+
const searchAPI = createSearchAPI({
|
|
49
|
+
searchProvider: 'crw',
|
|
50
|
+
crwApiKey: '',
|
|
51
|
+
});
|
|
52
|
+
expect(typeof searchAPI.getSources).toBe('function');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('throws for an invalid search provider with an updated message', () => {
|
|
56
|
+
expect(() =>
|
|
57
|
+
createSearchAPI({ searchProvider: 'bogus' as t.SearchProvider })
|
|
58
|
+
).toThrow(/'crw'/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('returns an error for empty CRW search queries', async () => {
|
|
62
|
+
const searchAPI = createSearchAPI({
|
|
63
|
+
searchProvider: 'crw',
|
|
64
|
+
crwApiKey: 'test-key',
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const result = await searchAPI.getSources({ query: ' ' });
|
|
68
|
+
|
|
69
|
+
expect(result).toEqual({
|
|
70
|
+
success: false,
|
|
71
|
+
error: 'Query cannot be empty',
|
|
72
|
+
});
|
|
73
|
+
expect(mockedAxios.post).not.toHaveBeenCalled();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('returns an error when the CRW search request fails', async () => {
|
|
77
|
+
mockedAxios.post.mockRejectedValueOnce(new Error('Network error'));
|
|
78
|
+
|
|
79
|
+
const searchAPI = createSearchAPI({
|
|
80
|
+
searchProvider: 'crw',
|
|
81
|
+
crwApiKey: 'test-key',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const result = await searchAPI.getSources({ query: 'example query' });
|
|
85
|
+
|
|
86
|
+
expect(result.success).toBe(false);
|
|
87
|
+
expect(result.error).toBe('fastCRW search request failed: Network error');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('maps the CRW search response fields', async () => {
|
|
91
|
+
mockedAxios.post.mockResolvedValueOnce({
|
|
92
|
+
data: {
|
|
93
|
+
success: true,
|
|
94
|
+
data: {
|
|
95
|
+
web: [
|
|
96
|
+
{ title: 'T', url: 'https://e.com', description: 'D', position: 1 },
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const searchAPI = createSearchAPI({
|
|
103
|
+
searchProvider: 'crw',
|
|
104
|
+
crwApiKey: 'test-key',
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const result = await searchAPI.getSources({ query: 'example query' });
|
|
108
|
+
|
|
109
|
+
const [url, payload, config] = mockedAxios.post.mock.calls[0];
|
|
110
|
+
expect(url).toBe('https://api.fastcrw.com/v1/search');
|
|
111
|
+
expect(payload).toEqual({
|
|
112
|
+
query: 'example query',
|
|
113
|
+
limit: 8,
|
|
114
|
+
sources: ['web'],
|
|
115
|
+
});
|
|
116
|
+
expect(
|
|
117
|
+
(config as { headers: Record<string, string> }).headers.Authorization
|
|
118
|
+
).toBe('Bearer test-key');
|
|
119
|
+
expect(result.success).toBe(true);
|
|
120
|
+
expect(result.data?.organic?.[0]).toEqual({
|
|
121
|
+
title: 'T',
|
|
122
|
+
link: 'https://e.com',
|
|
123
|
+
snippet: 'D',
|
|
124
|
+
position: 1,
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('uses the images source and maps image results', async () => {
|
|
129
|
+
mockedAxios.post.mockResolvedValueOnce({
|
|
130
|
+
data: {
|
|
131
|
+
success: true,
|
|
132
|
+
data: {
|
|
133
|
+
images: [
|
|
134
|
+
{
|
|
135
|
+
title: 'I',
|
|
136
|
+
url: 'https://e.com/page',
|
|
137
|
+
imageUrl: 'https://e.com/i.png',
|
|
138
|
+
position: 1,
|
|
139
|
+
},
|
|
140
|
+
{ title: 'no-image-url', url: 'https://e.com/x' },
|
|
141
|
+
],
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const searchAPI = createSearchAPI({
|
|
147
|
+
searchProvider: 'crw',
|
|
148
|
+
crwApiKey: 'test-key',
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const result = await searchAPI.getSources({
|
|
152
|
+
query: 'example query',
|
|
153
|
+
type: 'images',
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const [, payload] = mockedAxios.post.mock.calls[0];
|
|
157
|
+
expect((payload as t.CrwSearchPayload).sources).toEqual(['images']);
|
|
158
|
+
expect(result.data?.images).toEqual([
|
|
159
|
+
{
|
|
160
|
+
title: 'I',
|
|
161
|
+
imageUrl: 'https://e.com/i.png',
|
|
162
|
+
link: 'https://e.com/page',
|
|
163
|
+
position: 1,
|
|
164
|
+
},
|
|
165
|
+
]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('adds images to a web search when includeImages is set', async () => {
|
|
169
|
+
mockedAxios.post.mockResolvedValueOnce({
|
|
170
|
+
data: { success: true, data: { web: [] } },
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const searchAPI = createSearchAPI({
|
|
174
|
+
searchProvider: 'crw',
|
|
175
|
+
crwApiKey: 'test-key',
|
|
176
|
+
crwSearchOptions: { includeImages: true },
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await searchAPI.getSources({ query: 'example query' });
|
|
180
|
+
|
|
181
|
+
const [, payload] = mockedAxios.post.mock.calls[0];
|
|
182
|
+
expect((payload as t.CrwSearchPayload).sources).toEqual(['web', 'images']);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('uses the news source and maps news results', async () => {
|
|
186
|
+
mockedAxios.post.mockResolvedValueOnce({
|
|
187
|
+
data: {
|
|
188
|
+
success: true,
|
|
189
|
+
data: {
|
|
190
|
+
news: [
|
|
191
|
+
{
|
|
192
|
+
title: 'N',
|
|
193
|
+
url: 'https://news.example.com/story',
|
|
194
|
+
description: 'D',
|
|
195
|
+
publishedDate: '2026-07-01T20:40:00',
|
|
196
|
+
position: 1,
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const searchAPI = createSearchAPI({
|
|
204
|
+
searchProvider: 'crw',
|
|
205
|
+
crwApiKey: 'test-key',
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const result = await searchAPI.getSources({
|
|
209
|
+
query: 'example query',
|
|
210
|
+
type: 'news',
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const [, payload] = mockedAxios.post.mock.calls[0];
|
|
214
|
+
expect((payload as t.CrwSearchPayload).sources).toEqual(['news']);
|
|
215
|
+
expect(result.data?.news).toEqual([
|
|
216
|
+
{
|
|
217
|
+
title: 'N',
|
|
218
|
+
link: 'https://news.example.com/story',
|
|
219
|
+
snippet: 'D',
|
|
220
|
+
date: '2026-07-01T20:40:00',
|
|
221
|
+
source: 'news.example.com',
|
|
222
|
+
position: 1,
|
|
223
|
+
},
|
|
224
|
+
]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('routes a flat data array by category (documented OpenAPI shape)', async () => {
|
|
228
|
+
mockedAxios.post.mockResolvedValueOnce({
|
|
229
|
+
data: {
|
|
230
|
+
success: true,
|
|
231
|
+
data: [
|
|
232
|
+
{
|
|
233
|
+
title: 'W',
|
|
234
|
+
url: 'https://e.com/w',
|
|
235
|
+
snippet: 'S',
|
|
236
|
+
category: 'general',
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
title: 'N',
|
|
240
|
+
url: 'https://e.com/n',
|
|
241
|
+
snippet: 'S',
|
|
242
|
+
category: 'news',
|
|
243
|
+
},
|
|
244
|
+
{ title: 'I', url: 'https://e.com/i.png', category: 'images' },
|
|
245
|
+
{ title: 'U', url: 'https://e.com/u', snippet: 'S' },
|
|
246
|
+
],
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const searchAPI = createSearchAPI({
|
|
251
|
+
searchProvider: 'crw',
|
|
252
|
+
crwApiKey: 'test-key',
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const result = await searchAPI.getSources({ query: 'example query' });
|
|
256
|
+
|
|
257
|
+
expect(result.data?.organic?.map((r) => r.link)).toEqual([
|
|
258
|
+
'https://e.com/w',
|
|
259
|
+
'https://e.com/u',
|
|
260
|
+
]);
|
|
261
|
+
expect(result.data?.news?.[0].link).toBe('https://e.com/n');
|
|
262
|
+
expect(result.data?.images?.[0].imageUrl).toBe('https://e.com/i.png');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('unwraps a data.results wrapper (self-host shape)', async () => {
|
|
266
|
+
mockedAxios.post.mockResolvedValueOnce({
|
|
267
|
+
data: {
|
|
268
|
+
success: true,
|
|
269
|
+
data: {
|
|
270
|
+
results: {
|
|
271
|
+
web: [{ title: 'T', url: 'https://e.com', description: 'D' }],
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const searchAPI = createSearchAPI({
|
|
278
|
+
searchProvider: 'crw',
|
|
279
|
+
crwApiKey: 'test-key',
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const result = await searchAPI.getSources({ query: 'example query' });
|
|
283
|
+
|
|
284
|
+
expect(result.data?.organic?.[0].link).toBe('https://e.com');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('maps the date parameter to a qdr tbs filter', async () => {
|
|
288
|
+
mockedAxios.post.mockResolvedValueOnce({
|
|
289
|
+
data: { success: true, data: { web: [] } },
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const searchAPI = createSearchAPI({
|
|
293
|
+
searchProvider: 'crw',
|
|
294
|
+
crwApiKey: 'test-key',
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
await searchAPI.getSources({
|
|
298
|
+
query: 'example query',
|
|
299
|
+
date: DATE_RANGE.PAST_24_HOURS,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const [, payload] = mockedAxios.post.mock.calls[0];
|
|
303
|
+
expect((payload as t.CrwSearchPayload).tbs).toBe('qdr:d');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('omits tbs when no date is given', async () => {
|
|
307
|
+
mockedAxios.post.mockResolvedValueOnce({
|
|
308
|
+
data: { success: true, data: { web: [] } },
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
const searchAPI = createSearchAPI({
|
|
312
|
+
searchProvider: 'crw',
|
|
313
|
+
crwApiKey: 'test-key',
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
await searchAPI.getSources({ query: 'example query' });
|
|
317
|
+
|
|
318
|
+
const [, payload] = mockedAxios.post.mock.calls[0];
|
|
319
|
+
expect(payload as Record<string, unknown>).not.toHaveProperty('tbs');
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('surfaces an envelope failure with the error code', async () => {
|
|
323
|
+
mockedAxios.post.mockResolvedValueOnce({
|
|
324
|
+
data: {
|
|
325
|
+
success: false,
|
|
326
|
+
error: 'rate limited',
|
|
327
|
+
error_code: 'RATE_LIMIT',
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const searchAPI = createSearchAPI({
|
|
332
|
+
searchProvider: 'crw',
|
|
333
|
+
crwApiKey: 'test-key',
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const result = await searchAPI.getSources({ query: 'example query' });
|
|
337
|
+
|
|
338
|
+
expect(result).toEqual({
|
|
339
|
+
success: false,
|
|
340
|
+
error: 'fastCRW search failed: [RATE_LIMIT] rate limited',
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('honors the base-URL override for search', async () => {
|
|
345
|
+
mockedAxios.post.mockResolvedValueOnce({
|
|
346
|
+
data: { success: true, data: { web: [] } },
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
const searchAPI = createSearchAPI({
|
|
350
|
+
searchProvider: 'crw',
|
|
351
|
+
crwApiKey: 'test-key',
|
|
352
|
+
crwApiUrl: 'http://localhost:3000',
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
await searchAPI.getSources({ query: 'example query' });
|
|
356
|
+
|
|
357
|
+
const [url] = mockedAxios.post.mock.calls[0];
|
|
358
|
+
expect(url).toBe('http://localhost:3000/v1/search');
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('omits the Authorization header when keyless', async () => {
|
|
362
|
+
mockedAxios.post.mockResolvedValueOnce({
|
|
363
|
+
data: { success: true, data: { web: [] } },
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const searchAPI = createSearchAPI({
|
|
367
|
+
searchProvider: 'crw',
|
|
368
|
+
crwApiKey: '',
|
|
369
|
+
crwApiUrl: 'http://localhost:3000',
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
await searchAPI.getSources({ query: 'example query' });
|
|
373
|
+
|
|
374
|
+
const [, , config] = mockedAxios.post.mock.calls[0];
|
|
375
|
+
expect(
|
|
376
|
+
(config as { headers: Record<string, string> }).headers.Authorization
|
|
377
|
+
).toBeUndefined();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('drops results with an empty url', async () => {
|
|
381
|
+
mockedAxios.post.mockResolvedValueOnce({
|
|
382
|
+
data: {
|
|
383
|
+
success: true,
|
|
384
|
+
data: {
|
|
385
|
+
web: [
|
|
386
|
+
{ title: 'A', url: '', description: 'x' },
|
|
387
|
+
{ title: 'B', url: 'https://ok.com', description: 'y' },
|
|
388
|
+
],
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
const searchAPI = createSearchAPI({
|
|
394
|
+
searchProvider: 'crw',
|
|
395
|
+
crwApiKey: 'test-key',
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const result = await searchAPI.getSources({ query: 'example query' });
|
|
399
|
+
|
|
400
|
+
expect(result.data?.organic).toHaveLength(1);
|
|
401
|
+
expect(result.data?.organic?.[0].link).toBe('https://ok.com');
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
describe('CrwScraper', () => {
|
|
406
|
+
const originalUrl = process.env.CRW_API_URL;
|
|
407
|
+
|
|
408
|
+
beforeEach(() => {
|
|
409
|
+
jest.clearAllMocks();
|
|
410
|
+
delete process.env.CRW_API_URL;
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
afterAll(() => {
|
|
414
|
+
if (originalUrl === undefined) {
|
|
415
|
+
delete process.env.CRW_API_URL;
|
|
416
|
+
} else {
|
|
417
|
+
process.env.CRW_API_URL = originalUrl;
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
describe('constructor', () => {
|
|
422
|
+
it('warns when CRW_API_KEY is not set', () => {
|
|
423
|
+
const logger = { ...mockLogger, warn: jest.fn() } as unknown as t.Logger;
|
|
424
|
+
new CrwScraper({ apiKey: '', logger });
|
|
425
|
+
expect(logger.warn).toHaveBeenCalledWith(
|
|
426
|
+
'CRW_API_KEY is not set. Scraping will not work.'
|
|
427
|
+
);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('uses the default base URL', async () => {
|
|
431
|
+
mockedAxios.post.mockResolvedValueOnce({
|
|
432
|
+
data: { success: true, markdown: '# Hi' },
|
|
433
|
+
});
|
|
434
|
+
const scraper = createCrwScraper({ apiKey: 'k', logger: mockLogger });
|
|
435
|
+
await scraper.scrapeUrl('https://example.com');
|
|
436
|
+
const [url] = mockedAxios.post.mock.calls[0];
|
|
437
|
+
expect(url).toBe('https://api.fastcrw.com/v1/scrape');
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('honors the CRW_API_URL env override', async () => {
|
|
441
|
+
process.env.CRW_API_URL = 'http://localhost:3000';
|
|
442
|
+
mockedAxios.post.mockResolvedValueOnce({
|
|
443
|
+
data: { success: true, markdown: '# Hi' },
|
|
444
|
+
});
|
|
445
|
+
const scraper = createCrwScraper({ apiKey: 'k', logger: mockLogger });
|
|
446
|
+
await scraper.scrapeUrl('https://example.com');
|
|
447
|
+
const [url] = mockedAxios.post.mock.calls[0];
|
|
448
|
+
expect(url).toBe('http://localhost:3000/v1/scrape');
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('sends the render budget and pads the HTTP timeout', async () => {
|
|
452
|
+
mockedAxios.post.mockResolvedValueOnce({
|
|
453
|
+
data: { success: true, markdown: '# Hi' },
|
|
454
|
+
});
|
|
455
|
+
const scraper = createCrwScraper({ apiKey: 'k', logger: mockLogger });
|
|
456
|
+
await scraper.scrapeUrl('https://example.com');
|
|
457
|
+
const [, payload, config] = mockedAxios.post.mock.calls[0];
|
|
458
|
+
expect((payload as { timeout: number }).timeout).toBe(7500);
|
|
459
|
+
expect((payload as { deadlineMs: number }).deadlineMs).toBe(7500);
|
|
460
|
+
expect((config as { timeout: number }).timeout).toBe(12500);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it('clamps deadlineMs to the documented 60000 max', async () => {
|
|
464
|
+
mockedAxios.post.mockResolvedValueOnce({
|
|
465
|
+
data: { success: true, markdown: '# Hi' },
|
|
466
|
+
});
|
|
467
|
+
const scraper = createCrwScraper({
|
|
468
|
+
apiKey: 'k',
|
|
469
|
+
timeout: 90000,
|
|
470
|
+
logger: mockLogger,
|
|
471
|
+
});
|
|
472
|
+
await scraper.scrapeUrl('https://example.com');
|
|
473
|
+
const [, payload] = mockedAxios.post.mock.calls[0];
|
|
474
|
+
expect((payload as { timeout: number }).timeout).toBe(90000);
|
|
475
|
+
expect((payload as { deadlineMs: number }).deadlineMs).toBe(60000);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('requests markdown + html by default', async () => {
|
|
479
|
+
mockedAxios.post.mockResolvedValueOnce({
|
|
480
|
+
data: { success: true, markdown: '# Hi' },
|
|
481
|
+
});
|
|
482
|
+
const scraper = createCrwScraper({ apiKey: 'k', logger: mockLogger });
|
|
483
|
+
await scraper.scrapeUrl('https://example.com');
|
|
484
|
+
const [, payload] = mockedAxios.post.mock.calls[0];
|
|
485
|
+
expect((payload as { formats: string[] }).formats).toEqual([
|
|
486
|
+
'markdown',
|
|
487
|
+
'html',
|
|
488
|
+
]);
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it('does not early-return on a missing key', async () => {
|
|
493
|
+
mockedAxios.post.mockResolvedValueOnce({
|
|
494
|
+
data: { success: true, markdown: '# Hi' },
|
|
495
|
+
});
|
|
496
|
+
const scraper = createCrwScraper({ apiKey: '', logger: mockLogger });
|
|
497
|
+
await scraper.scrapeUrl('https://example.com');
|
|
498
|
+
expect(mockedAxios.post).toHaveBeenCalledTimes(1);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('attaches the Bearer header only when a key is present', async () => {
|
|
502
|
+
mockedAxios.post.mockResolvedValue({
|
|
503
|
+
data: { success: true, markdown: '# Hi' },
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
const withKey = createCrwScraper({ apiKey: 'k', logger: mockLogger });
|
|
507
|
+
await withKey.scrapeUrl('https://example.com');
|
|
508
|
+
const [, , withKeyConfig] = mockedAxios.post.mock.calls[0];
|
|
509
|
+
expect(
|
|
510
|
+
(withKeyConfig as { headers: Record<string, string> }).headers
|
|
511
|
+
.Authorization
|
|
512
|
+
).toBe('Bearer k');
|
|
513
|
+
|
|
514
|
+
mockedAxios.post.mockClear();
|
|
515
|
+
|
|
516
|
+
const keyless = createCrwScraper({ apiKey: '', logger: mockLogger });
|
|
517
|
+
await keyless.scrapeUrl('https://example.com');
|
|
518
|
+
const [, , keylessConfig] = mockedAxios.post.mock.calls[0];
|
|
519
|
+
expect(
|
|
520
|
+
(keylessConfig as { headers: Record<string, string> }).headers
|
|
521
|
+
.Authorization
|
|
522
|
+
).toBeUndefined();
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it('reads the nested data container from cloud responses', async () => {
|
|
526
|
+
mockedAxios.post.mockResolvedValueOnce({
|
|
527
|
+
data: {
|
|
528
|
+
success: true,
|
|
529
|
+
data: {
|
|
530
|
+
markdown: '# Hi',
|
|
531
|
+
html: '<p>x</p>',
|
|
532
|
+
metadata: { title: 't' },
|
|
533
|
+
links: [],
|
|
534
|
+
},
|
|
535
|
+
},
|
|
536
|
+
});
|
|
537
|
+
const scraper = createCrwScraper({ apiKey: 'k', logger: mockLogger });
|
|
538
|
+
const result = await scraper.scrapeUrl('https://example.com');
|
|
539
|
+
expect(result).toEqual([
|
|
540
|
+
'https://example.com',
|
|
541
|
+
{
|
|
542
|
+
success: true,
|
|
543
|
+
data: {
|
|
544
|
+
markdown: '# Hi',
|
|
545
|
+
html: '<p>x</p>',
|
|
546
|
+
metadata: { title: 't' },
|
|
547
|
+
links: [],
|
|
548
|
+
rawHtml: undefined,
|
|
549
|
+
plainText: undefined,
|
|
550
|
+
screenshot: undefined,
|
|
551
|
+
},
|
|
552
|
+
},
|
|
553
|
+
]);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it('normalizes top-level fields into the nested data shape', async () => {
|
|
557
|
+
mockedAxios.post.mockResolvedValueOnce({
|
|
558
|
+
data: {
|
|
559
|
+
success: true,
|
|
560
|
+
markdown: '# Hi',
|
|
561
|
+
html: '<p>x</p>',
|
|
562
|
+
metadata: { title: 't' },
|
|
563
|
+
links: [],
|
|
564
|
+
},
|
|
565
|
+
});
|
|
566
|
+
const scraper = createCrwScraper({ apiKey: 'k', logger: mockLogger });
|
|
567
|
+
const result = await scraper.scrapeUrl('https://example.com');
|
|
568
|
+
expect(result).toEqual([
|
|
569
|
+
'https://example.com',
|
|
570
|
+
{
|
|
571
|
+
success: true,
|
|
572
|
+
data: {
|
|
573
|
+
markdown: '# Hi',
|
|
574
|
+
html: '<p>x</p>',
|
|
575
|
+
metadata: { title: 't' },
|
|
576
|
+
links: [],
|
|
577
|
+
rawHtml: undefined,
|
|
578
|
+
plainText: undefined,
|
|
579
|
+
screenshot: undefined,
|
|
580
|
+
},
|
|
581
|
+
},
|
|
582
|
+
]);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it('strips inline base64 image data-URIs from text content but keeps the screenshot', async () => {
|
|
586
|
+
const bigB64 = 'A'.repeat(5000);
|
|
587
|
+
mockedAxios.post.mockResolvedValueOnce({
|
|
588
|
+
data: {
|
|
589
|
+
success: true,
|
|
590
|
+
data: {
|
|
591
|
+
markdown: ` text`,
|
|
592
|
+
html: `<img src="data:image/jpeg;base64,${bigB64}"><p>x</p>`,
|
|
593
|
+
screenshot: `data:image/png;base64,${bigB64}`,
|
|
594
|
+
},
|
|
595
|
+
},
|
|
596
|
+
});
|
|
597
|
+
const scraper = createCrwScraper({ apiKey: 'k', logger: mockLogger });
|
|
598
|
+
const [, res] = await scraper.scrapeUrl('https://example.com');
|
|
599
|
+
expect(res.data?.markdown).toBe(
|
|
600
|
+
' text'
|
|
601
|
+
);
|
|
602
|
+
expect(res.data?.html).toBe(
|
|
603
|
+
'<img src="data:base64-content-removed"><p>x</p>'
|
|
604
|
+
);
|
|
605
|
+
// `screenshot` is base64 by design and never reaches the content processor.
|
|
606
|
+
expect(res.data?.screenshot).toBe(`data:image/png;base64,${bigB64}`);
|
|
607
|
+
expect(res.data?.markdown).not.toContain(bigB64);
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
describe('extractContent', () => {
|
|
611
|
+
const scraper = createCrwScraper({ apiKey: 'k', logger: mockLogger });
|
|
612
|
+
|
|
613
|
+
it('processes markdown + html into ref markers', () => {
|
|
614
|
+
const [content, references] = scraper.extractContent({
|
|
615
|
+
success: true,
|
|
616
|
+
data: { markdown: '# Hi', html: '<p>x</p>' },
|
|
617
|
+
});
|
|
618
|
+
expect(typeof content).toBe('string');
|
|
619
|
+
expect(references).toHaveProperty('links');
|
|
620
|
+
expect(references).toHaveProperty('images');
|
|
621
|
+
expect(references).toHaveProperty('videos');
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it('runs the ref-marker pass from rawHtml when html is absent', () => {
|
|
625
|
+
const [content, references] = scraper.extractContent({
|
|
626
|
+
success: true,
|
|
627
|
+
data: { markdown: '# Hi', rawHtml: '<p>x</p>' },
|
|
628
|
+
});
|
|
629
|
+
expect(typeof content).toBe('string');
|
|
630
|
+
expect(references).toHaveProperty('links');
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it('returns empty on a failed response', () => {
|
|
634
|
+
expect(scraper.extractContent({ success: false })).toEqual([
|
|
635
|
+
'',
|
|
636
|
+
undefined,
|
|
637
|
+
]);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it('falls back to plainText when no markdown/html/rawHtml', () => {
|
|
641
|
+
expect(
|
|
642
|
+
scraper.extractContent({
|
|
643
|
+
success: true,
|
|
644
|
+
data: {
|
|
645
|
+
plainText: 'plain body',
|
|
646
|
+
markdown: undefined,
|
|
647
|
+
html: undefined,
|
|
648
|
+
rawHtml: undefined,
|
|
649
|
+
},
|
|
650
|
+
})
|
|
651
|
+
).toEqual(['plain body', undefined]);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it('falls back to rawHtml when no markdown/html', () => {
|
|
655
|
+
expect(
|
|
656
|
+
scraper.extractContent({
|
|
657
|
+
success: true,
|
|
658
|
+
data: { rawHtml: '<x>', markdown: undefined, html: undefined },
|
|
659
|
+
})
|
|
660
|
+
).toEqual(['<x>', undefined]);
|
|
661
|
+
});
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
describe('extractMetadata', () => {
|
|
665
|
+
const scraper = createCrwScraper({ apiKey: 'k', logger: mockLogger });
|
|
666
|
+
|
|
667
|
+
it('returns the metadata object', () => {
|
|
668
|
+
expect(
|
|
669
|
+
scraper.extractMetadata({
|
|
670
|
+
success: true,
|
|
671
|
+
data: { metadata: { title: 't' } },
|
|
672
|
+
})
|
|
673
|
+
).toEqual({ title: 't' });
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it('returns {} on a failed response', () => {
|
|
677
|
+
expect(scraper.extractMetadata({ success: false })).toEqual({});
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it('returns {} when metadata is missing', () => {
|
|
681
|
+
expect(scraper.extractMetadata({ success: true, data: {} })).toEqual({});
|
|
682
|
+
});
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it('normalizes an envelope failure with an error code', async () => {
|
|
686
|
+
mockedAxios.post.mockResolvedValueOnce({
|
|
687
|
+
data: { success: false, error: 'blocked', error_code: 'X' },
|
|
688
|
+
});
|
|
689
|
+
const scraper = createCrwScraper({ apiKey: 'k', logger: mockLogger });
|
|
690
|
+
const result = await scraper.scrapeUrl('https://example.com');
|
|
691
|
+
expect(result).toEqual([
|
|
692
|
+
'https://example.com',
|
|
693
|
+
{ success: false, error: '[X] blocked', error_code: 'X' },
|
|
694
|
+
]);
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
it('normalizes an envelope failure without an error code', async () => {
|
|
698
|
+
mockedAxios.post.mockResolvedValueOnce({
|
|
699
|
+
data: { success: false, error: 'oops' },
|
|
700
|
+
});
|
|
701
|
+
const scraper = createCrwScraper({ apiKey: 'k', logger: mockLogger });
|
|
702
|
+
const result = await scraper.scrapeUrl('https://example.com');
|
|
703
|
+
expect(result).toEqual([
|
|
704
|
+
'https://example.com',
|
|
705
|
+
{ success: false, error: 'oops', error_code: undefined },
|
|
706
|
+
]);
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
it('returns a failure tuple on a network error', async () => {
|
|
710
|
+
mockedAxios.post.mockRejectedValueOnce(new Error('Network error'));
|
|
711
|
+
const scraper = createCrwScraper({ apiKey: 'k', logger: mockLogger });
|
|
712
|
+
const [url, response] = await scraper.scrapeUrl('https://example.com');
|
|
713
|
+
expect(url).toBe('https://example.com');
|
|
714
|
+
expect(response.success).toBe(false);
|
|
715
|
+
expect(response.error).toBe('fastCRW API request failed: Network error');
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
it('assembles the payload via omitUndefined', async () => {
|
|
719
|
+
mockedAxios.post.mockResolvedValueOnce({
|
|
720
|
+
data: { success: true, markdown: '# Hi' },
|
|
721
|
+
});
|
|
722
|
+
const scraper = createCrwScraper({ apiKey: 'k', logger: mockLogger });
|
|
723
|
+
await scraper.scrapeUrl('https://example.com', {
|
|
724
|
+
onlyMainContent: true,
|
|
725
|
+
waitFor: 1000,
|
|
726
|
+
formats: ['markdown'],
|
|
727
|
+
renderJs: true,
|
|
728
|
+
});
|
|
729
|
+
const [, payload] = mockedAxios.post.mock.calls[0];
|
|
730
|
+
expect(payload).toMatchObject({
|
|
731
|
+
url: 'https://example.com',
|
|
732
|
+
onlyMainContent: true,
|
|
733
|
+
waitFor: 1000,
|
|
734
|
+
formats: ['markdown'],
|
|
735
|
+
renderJs: true,
|
|
736
|
+
});
|
|
737
|
+
expect(payload as Record<string, unknown>).not.toHaveProperty('proxy');
|
|
738
|
+
expect(payload as Record<string, unknown>).not.toHaveProperty(
|
|
739
|
+
'cssSelector'
|
|
740
|
+
);
|
|
741
|
+
});
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
describe('CRW search tool wiring', () => {
|
|
745
|
+
beforeEach(() => {
|
|
746
|
+
jest.clearAllMocks();
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
it('runs the search then scrape sequence', async () => {
|
|
750
|
+
mockedAxios.post
|
|
751
|
+
.mockResolvedValueOnce({
|
|
752
|
+
data: {
|
|
753
|
+
success: true,
|
|
754
|
+
data: {
|
|
755
|
+
web: [{ title: 'T', url: 'https://ex.com/a', description: 'D' }],
|
|
756
|
+
},
|
|
757
|
+
},
|
|
758
|
+
})
|
|
759
|
+
.mockResolvedValueOnce({
|
|
760
|
+
data: {
|
|
761
|
+
success: true,
|
|
762
|
+
data: {
|
|
763
|
+
markdown: '# A',
|
|
764
|
+
html: '<p>a</p>',
|
|
765
|
+
metadata: { title: 'T' },
|
|
766
|
+
links: [],
|
|
767
|
+
},
|
|
768
|
+
},
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
const searchTool = createSearchTool({
|
|
772
|
+
searchProvider: 'crw',
|
|
773
|
+
scraperProvider: 'crw',
|
|
774
|
+
crwApiKey: 'k',
|
|
775
|
+
topResults: 1,
|
|
776
|
+
rerankerType: 'none',
|
|
777
|
+
logger: mockLogger,
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
await searchTool.invoke({ query: 'hello' });
|
|
781
|
+
|
|
782
|
+
expect(mockedAxios.post).toHaveBeenCalledTimes(2);
|
|
783
|
+
const [searchUrl, , searchConfig] = mockedAxios.post.mock.calls[0];
|
|
784
|
+
const [scrapeUrl, , scrapeConfig] = mockedAxios.post.mock.calls[1];
|
|
785
|
+
expect((searchUrl as string).endsWith('/v1/search')).toBe(true);
|
|
786
|
+
expect(
|
|
787
|
+
(searchConfig as { headers: Record<string, string> }).headers
|
|
788
|
+
.Authorization
|
|
789
|
+
).toBe('Bearer k');
|
|
790
|
+
expect((scrapeUrl as string).endsWith('/v1/scrape')).toBe(true);
|
|
791
|
+
expect(
|
|
792
|
+
(scrapeConfig as { headers: Record<string, string> }).headers
|
|
793
|
+
.Authorization
|
|
794
|
+
).toBe('Bearer k');
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
it('keeps the shared base and scraper override independent', async () => {
|
|
798
|
+
mockedAxios.post
|
|
799
|
+
.mockResolvedValueOnce({
|
|
800
|
+
data: {
|
|
801
|
+
success: true,
|
|
802
|
+
data: {
|
|
803
|
+
web: [{ title: 'T', url: 'https://ex.com/a', description: 'D' }],
|
|
804
|
+
},
|
|
805
|
+
},
|
|
806
|
+
})
|
|
807
|
+
.mockResolvedValueOnce({
|
|
808
|
+
data: {
|
|
809
|
+
success: true,
|
|
810
|
+
data: {
|
|
811
|
+
markdown: '# A',
|
|
812
|
+
html: '<p>a</p>',
|
|
813
|
+
metadata: { title: 'T' },
|
|
814
|
+
links: [],
|
|
815
|
+
},
|
|
816
|
+
},
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
const searchTool = createSearchTool({
|
|
820
|
+
searchProvider: 'crw',
|
|
821
|
+
scraperProvider: 'crw',
|
|
822
|
+
crwApiKey: 'k',
|
|
823
|
+
crwScraperOptions: { apiUrl: 'http://localhost:3000' },
|
|
824
|
+
topResults: 1,
|
|
825
|
+
rerankerType: 'none',
|
|
826
|
+
logger: mockLogger,
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
await searchTool.invoke({ query: 'hello' });
|
|
830
|
+
|
|
831
|
+
const [searchUrl] = mockedAxios.post.mock.calls[0];
|
|
832
|
+
const [scrapeUrl] = mockedAxios.post.mock.calls[1];
|
|
833
|
+
expect(searchUrl).toBe('https://api.fastcrw.com/v1/search');
|
|
834
|
+
expect(scrapeUrl).toBe('http://localhost:3000/v1/scrape');
|
|
835
|
+
});
|
|
836
|
+
});
|