search-web-api 1.0.13 → 1.0.15

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/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "search-web-api",
3
3
  "description": "70+ search engines across 10 categories and Scrape Extract API",
4
4
  "type": "module",
5
- "version": "1.0.13",
5
+ "version": "1.0.15",
6
6
  "author": "vtempest",
7
7
  "license": "MIT",
8
8
  "repository": {
@@ -10,6 +10,16 @@
10
10
  "url": "git+https://github.com/OpenSourceAGI/qwksearch-research-agent.git",
11
11
  "directory": "packages/search-web-api"
12
12
  },
13
+ "exports": {
14
+ ".": {
15
+ "import": "./src/search.ts",
16
+ "require": "./src/search.ts"
17
+ },
18
+ "./*": {
19
+ "import": "./src/*",
20
+ "require": "./src/*"
21
+ }
22
+ },
13
23
  "scripts": {
14
24
  "dev": "bun --watch demo/index.ts",
15
25
  "start": "bun demo/index.ts",
@@ -18,9 +28,11 @@
18
28
  "dependencies": {
19
29
  "@huggingface/transformers": "^3.8.1",
20
30
  "@scalar/hono-api-reference": "^0.9.26",
31
+ "chrono-node": "^2.9.0",
21
32
  "grab-url": "^1.0.8",
22
33
  "hono": "^4.6.12",
23
- "linkedom": "^0.18.0"
34
+ "linkedom": "^0.18.0",
35
+ "tldts": "^7.0.25"
24
36
  },
25
37
  "devDependencies": {
26
38
  "@types/node": "^25.9.3",
@@ -29,4 +41,4 @@
29
41
  "typescript": "^5.9.3",
30
42
  "vitest": "^4.0.18"
31
43
  }
32
- }
44
+ }
@@ -0,0 +1,529 @@
1
+ /**
2
+ * @fileoverview Unit tests for SearXNG search functionality
3
+ */
4
+ import { searchWeb, searchSearxng } from "../public-searxng";
5
+ import grab from "grab-url";
6
+
7
+ // Mock grab-url
8
+ jest.mock("grab-url");
9
+ const mockGrab = grab as jest.MockedFunction<typeof grab>;
10
+
11
+ describe("searchWeb", () => {
12
+ beforeEach(() => {
13
+ jest.clearAllMocks();
14
+ });
15
+
16
+ describe("Private SearXNG instance (JSON)", () => {
17
+ it("should search with private SearXNG instance", async () => {
18
+ const mockResults = {
19
+ results: [
20
+ {
21
+ title: "Test Result 1",
22
+ url: "https://example.com/1",
23
+ content: "Test snippet 1",
24
+ score: 0.95,
25
+ metadata: "2024-01-01 | Example News",
26
+ },
27
+ {
28
+ title: "Test Result 2",
29
+ url: "https://example.com/2",
30
+ content: "Test snippet 2",
31
+ score: 0.85,
32
+ },
33
+ ],
34
+ suggestions: ["suggestion1", "suggestion2"],
35
+ infoboxes: [],
36
+ };
37
+
38
+ mockGrab.mockResolvedValueOnce(mockResults);
39
+
40
+ const result = await searchWeb("test query", {
41
+ privateSearxng: "https://search.example.com",
42
+ });
43
+
44
+ expect(mockGrab).toHaveBeenCalledWith(
45
+ "https://search.example.com/search",
46
+ expect.objectContaining({
47
+ q: expect.stringContaining("test"),
48
+ format: "json",
49
+ })
50
+ );
51
+
52
+ expect(result).toHaveProperty("results");
53
+ expect(result).toHaveProperty("suggestions");
54
+ if (!Array.isArray(result)) {
55
+ expect(result.results).toHaveLength(2);
56
+ expect(result.results[0].title).toBe("Test Result 1");
57
+ expect(result.results[0].score).toBe(0.95);
58
+ }
59
+ });
60
+
61
+ it("should parse metadata for date and source", async () => {
62
+ const mockResults = {
63
+ results: [
64
+ {
65
+ title: "Article with Metadata",
66
+ url: "https://example.com/article",
67
+ content: "Content",
68
+ score: 0.9,
69
+ metadata: "Jan 15, 2024 | TechNews",
70
+ },
71
+ ],
72
+ suggestions: [],
73
+ infoboxes: [],
74
+ };
75
+
76
+ mockGrab.mockResolvedValueOnce(mockResults);
77
+
78
+ const result = await searchWeb("test", {
79
+ privateSearxng: "https://search.example.com",
80
+ });
81
+
82
+ if (!Array.isArray(result) && result.results.length > 0) {
83
+ const firstResult = result.results[0];
84
+ expect(firstResult.date).toBeDefined();
85
+ expect(firstResult.source).toBeDefined();
86
+ }
87
+ });
88
+
89
+ it("should clean HTML entities from titles", async () => {
90
+ const mockResults = {
91
+ results: [
92
+ {
93
+ title: "Test &amp; Title with &lt;HTML&gt; Entities",
94
+ url: "https://example.com",
95
+ content: "Content",
96
+ score: 0.9,
97
+ },
98
+ ],
99
+ suggestions: [],
100
+ infoboxes: [],
101
+ };
102
+
103
+ mockGrab.mockResolvedValueOnce(mockResults);
104
+
105
+ const result = await searchWeb("test", {
106
+ privateSearxng: "https://search.example.com",
107
+ });
108
+
109
+ if (!Array.isArray(result) && result.results.length > 0) {
110
+ expect(result.results[0].title).not.toContain("&amp;");
111
+ expect(result.results[0].title).toContain("&");
112
+ expect(result.results[0].title).not.toContain("&lt;");
113
+ }
114
+ });
115
+
116
+ it("should handle breadcrumbed titles", async () => {
117
+ const mockResults = {
118
+ results: [
119
+ {
120
+ title: "Site Name | Very Long Article Title Here",
121
+ url: "https://example.com",
122
+ content: "Content",
123
+ score: 0.9,
124
+ },
125
+ ],
126
+ suggestions: [],
127
+ infoboxes: [],
128
+ };
129
+
130
+ mockGrab.mockResolvedValueOnce(mockResults);
131
+
132
+ const result = await searchWeb("test", {
133
+ privateSearxng: "https://search.example.com",
134
+ });
135
+
136
+ if (!Array.isArray(result) && result.results.length > 0) {
137
+ // Should extract the longest part
138
+ expect(result.results[0].title).toContain("Very Long Article Title Here");
139
+ }
140
+ });
141
+
142
+ it("should add favicon URLs", async () => {
143
+ const mockResults = {
144
+ results: [
145
+ {
146
+ title: "Test",
147
+ url: "https://example.com/page",
148
+ content: "Content",
149
+ score: 0.9,
150
+ },
151
+ ],
152
+ suggestions: [],
153
+ infoboxes: [],
154
+ };
155
+
156
+ mockGrab.mockResolvedValueOnce(mockResults);
157
+
158
+ const result = await searchWeb("test", {
159
+ privateSearxng: "https://search.example.com",
160
+ });
161
+
162
+ if (!Array.isArray(result) && result.results.length > 0) {
163
+ expect(result.results[0].favicon).toContain("googleusercontent.com");
164
+ expect(result.results[0].favicon).toContain("example.com");
165
+ }
166
+ });
167
+
168
+ it("should handle string response by parsing JSON", async () => {
169
+ const mockResultsString = JSON.stringify({
170
+ results: [
171
+ {
172
+ title: "Test",
173
+ url: "https://example.com",
174
+ content: "Content",
175
+ score: 0.9,
176
+ },
177
+ ],
178
+ suggestions: [],
179
+ infoboxes: [],
180
+ });
181
+
182
+ mockGrab.mockResolvedValueOnce(mockResultsString);
183
+
184
+ const result = await searchWeb("test", {
185
+ privateSearxng: "https://search.example.com",
186
+ });
187
+
188
+ if (!Array.isArray(result)) {
189
+ expect(result.results).toHaveLength(1);
190
+ }
191
+ });
192
+
193
+ it("should handle invalid JSON gracefully", async () => {
194
+ mockGrab.mockResolvedValueOnce("Not valid JSON");
195
+
196
+ const result = await searchWeb("test", {
197
+ privateSearxng: "https://search.example.com",
198
+ });
199
+
200
+ if (!Array.isArray(result)) {
201
+ expect(result.results).toEqual([]);
202
+ expect(result.suggestions).toEqual([]);
203
+ }
204
+ });
205
+
206
+ it("should validate URL paths", async () => {
207
+ const mockResults = {
208
+ results: [
209
+ {
210
+ title: "Domain Only",
211
+ url: "https://example.com/", // Just domain
212
+ content: "Content",
213
+ score: 0.9,
214
+ },
215
+ {
216
+ title: "Full Path",
217
+ url: "https://example.com/article/page",
218
+ content: "Content",
219
+ score: 0.8,
220
+ },
221
+ ],
222
+ suggestions: [],
223
+ infoboxes: [],
224
+ };
225
+
226
+ const consoleSpy = jest.spyOn(console, "warn").mockImplementation();
227
+ mockGrab.mockResolvedValueOnce(mockResults);
228
+
229
+ const result = await searchWeb("test", {
230
+ privateSearxng: "https://search.example.com",
231
+ });
232
+
233
+ expect(consoleSpy).toHaveBeenCalledWith(
234
+ expect.stringContaining("domain-only"),
235
+ expect.any(String)
236
+ );
237
+ consoleSpy.mockRestore();
238
+ });
239
+ });
240
+
241
+ describe("Public SearXNG instance (HTML scraping)", () => {
242
+ it("should scrape results from HTML", async () => {
243
+ const mockHtml = `
244
+ <article class="result">
245
+ <h3><a href="https://example.com/1">First Result</a></h3>
246
+ <p class="content">First snippet</p>
247
+ </article>
248
+ <article class="result">
249
+ <h3><a href="https://example.com/2">Second Result</a></h3>
250
+ <p class="content">Second snippet</p>
251
+ </article>
252
+ `;
253
+
254
+ mockGrab.mockResolvedValueOnce(mockHtml);
255
+
256
+ const result = await searchWeb("test", {
257
+ privateSearxng: false,
258
+ });
259
+
260
+ expect(Array.isArray(result)).toBe(true);
261
+ if (Array.isArray(result)) {
262
+ expect(result).toHaveLength(2);
263
+ expect(result[0].title).toBe("First Result");
264
+ expect(result[0].url).toBe("https://example.com/1");
265
+ expect(result[0].snippet).toBe("First snippet");
266
+ }
267
+ });
268
+
269
+ it("should handle HTML entities in scraped content", async () => {
270
+ const mockHtml = `
271
+ <article class="result">
272
+ <h3><a href="https://example.com?param=value&amp;other=test">Test &amp; Title</a></h3>
273
+ <p class="content">Content with &lt;entities&gt;</p>
274
+ </article>
275
+ `;
276
+
277
+ mockGrab.mockResolvedValueOnce(mockHtml);
278
+
279
+ const result = await searchWeb("test", {
280
+ privateSearxng: false,
281
+ });
282
+
283
+ if (Array.isArray(result) && result.length > 0) {
284
+ expect(result[0].title).toContain("&");
285
+ expect(result[0].title).not.toContain("&amp;");
286
+ expect(result[0].url).toContain("&");
287
+ expect(result[0].snippet).toContain("<entities>");
288
+ }
289
+ });
290
+
291
+ it("should add favicons to scraped results", async () => {
292
+ const mockHtml = `
293
+ <article class="result">
294
+ <h3><a href="https://example.com/page">Test</a></h3>
295
+ <p class="content">Content</p>
296
+ </article>
297
+ `;
298
+
299
+ mockGrab.mockResolvedValueOnce(mockHtml);
300
+
301
+ const result = await searchWeb("test", {
302
+ privateSearxng: false,
303
+ });
304
+
305
+ if (Array.isArray(result) && result.length > 0) {
306
+ expect(result[0].favicon).toContain("google.com/s2/favicons");
307
+ expect(result[0].domain).toBe("example.com");
308
+ }
309
+ });
310
+
311
+ it("should validate URLs in scraped results", async () => {
312
+ const mockHtml = `
313
+ <article class="result">
314
+ <h3><a href="https://example.com/">Domain Only</a></h3>
315
+ <p class="content">Content</p>
316
+ </article>
317
+ `;
318
+
319
+ const consoleSpy = jest.spyOn(console, "warn").mockImplementation();
320
+ mockGrab.mockResolvedValueOnce(mockHtml);
321
+
322
+ await searchWeb("test", { privateSearxng: false });
323
+
324
+ expect(consoleSpy).toHaveBeenCalledWith(
325
+ expect.stringContaining("domain-only"),
326
+ expect.any(String)
327
+ );
328
+ consoleSpy.mockRestore();
329
+ });
330
+ });
331
+
332
+ describe("Retry logic", () => {
333
+ it("should retry on fetch failure", async () => {
334
+ mockGrab
335
+ .mockRejectedValueOnce(new Error("Network error"))
336
+ .mockResolvedValueOnce({
337
+ results: [
338
+ { title: "Retry Success", url: "https://example.com", content: "Content", score: 0.9 },
339
+ ],
340
+ suggestions: [],
341
+ infoboxes: [],
342
+ });
343
+
344
+ const result = await searchWeb("test", {
345
+ privateSearxng: "https://search.example.com",
346
+ maxRetries: 3,
347
+ });
348
+
349
+ expect(mockGrab).toHaveBeenCalledTimes(2);
350
+ if (!Array.isArray(result)) {
351
+ expect(result.results[0].title).toBe("Retry Success");
352
+ }
353
+ });
354
+
355
+ it("should retry on empty results", async () => {
356
+ mockGrab
357
+ .mockResolvedValueOnce("") // Empty HTML
358
+ .mockResolvedValueOnce(`
359
+ <article class="result">
360
+ <h3><a href="https://example.com">Retry Result</a></h3>
361
+ <p class="content">Found on retry</p>
362
+ </article>
363
+ `);
364
+
365
+ const result = await searchWeb("test", {
366
+ privateSearxng: false,
367
+ maxRetries: 2,
368
+ });
369
+
370
+ expect(mockGrab).toHaveBeenCalledTimes(2);
371
+ if (Array.isArray(result)) {
372
+ expect(result[0].title).toBe("Retry Result");
373
+ }
374
+ });
375
+
376
+ it("should return empty array after exhausting retries", async () => {
377
+ mockGrab.mockRejectedValue(new Error("Network error"));
378
+
379
+ const result = await searchWeb("test", {
380
+ privateSearxng: "https://search.example.com",
381
+ maxRetries: 2,
382
+ });
383
+
384
+ expect(mockGrab).toHaveBeenCalledTimes(3); // Initial + 2 retries
385
+ expect(result).toEqual([]);
386
+ });
387
+ });
388
+
389
+ describe("Search parameters", () => {
390
+ it("should apply category filter", async () => {
391
+ mockGrab.mockResolvedValueOnce({
392
+ results: [],
393
+ suggestions: [],
394
+ infoboxes: [],
395
+ });
396
+
397
+ await searchWeb("test", {
398
+ privateSearxng: "https://search.example.com",
399
+ category: "news",
400
+ });
401
+
402
+ expect(mockGrab).toHaveBeenCalledWith(
403
+ expect.any(String),
404
+ expect.objectContaining({
405
+ category_news: 1,
406
+ })
407
+ );
408
+ });
409
+
410
+ it("should apply recency filter", async () => {
411
+ mockGrab.mockResolvedValueOnce({
412
+ results: [],
413
+ suggestions: [],
414
+ infoboxes: [],
415
+ });
416
+
417
+ await searchWeb("test", {
418
+ privateSearxng: "https://search.example.com",
419
+ recency: "week",
420
+ });
421
+
422
+ expect(mockGrab).toHaveBeenCalledWith(
423
+ expect.any(String),
424
+ expect.objectContaining({
425
+ time_range: "week",
426
+ })
427
+ );
428
+ });
429
+
430
+ it("should handle pagination", async () => {
431
+ mockGrab.mockResolvedValueOnce({
432
+ results: [],
433
+ suggestions: [],
434
+ infoboxes: [],
435
+ });
436
+
437
+ await searchWeb("test", {
438
+ privateSearxng: "https://search.example.com",
439
+ page: 3,
440
+ });
441
+
442
+ expect(mockGrab).toHaveBeenCalledWith(
443
+ expect.any(String),
444
+ expect.objectContaining({
445
+ pageno: 3,
446
+ })
447
+ );
448
+ });
449
+
450
+ it("should apply safesearch", async () => {
451
+ mockGrab.mockResolvedValueOnce({
452
+ results: [],
453
+ suggestions: [],
454
+ infoboxes: [],
455
+ });
456
+
457
+ await searchWeb("test", {
458
+ privateSearxng: "https://search.example.com",
459
+ safesearch: true,
460
+ });
461
+
462
+ expect(mockGrab).toHaveBeenCalledWith(
463
+ expect.any(String),
464
+ expect.objectContaining({
465
+ safesearch: "1",
466
+ })
467
+ );
468
+ });
469
+
470
+ it("should use custom language", async () => {
471
+ mockGrab.mockResolvedValueOnce({
472
+ results: [],
473
+ suggestions: [],
474
+ infoboxes: [],
475
+ });
476
+
477
+ await searchWeb("test", {
478
+ privateSearxng: "https://search.example.com",
479
+ lang: "es",
480
+ });
481
+
482
+ expect(mockGrab).toHaveBeenCalledWith(
483
+ expect.any(String),
484
+ expect.objectContaining({
485
+ language: "es",
486
+ })
487
+ );
488
+ });
489
+ });
490
+ });
491
+
492
+ describe("searchSearxng", () => {
493
+ beforeEach(() => {
494
+ jest.clearAllMocks();
495
+ });
496
+
497
+ it("should adapt options to searchWeb", async () => {
498
+ mockGrab.mockResolvedValueOnce({
499
+ results: [{ title: "Test", url: "https://example.com", content: "Content", score: 0.9 }],
500
+ suggestions: ["test1"],
501
+ infoboxes: [],
502
+ });
503
+
504
+ const result = await searchSearxng("test query", {
505
+ categories: ["news"],
506
+ pageno: 2,
507
+ language: "fr",
508
+ });
509
+
510
+ expect(result).toHaveProperty("results");
511
+ expect(result).toHaveProperty("suggestions");
512
+ expect(result.results).toHaveLength(1);
513
+ expect(result.suggestions).toHaveLength(1);
514
+ });
515
+
516
+ it("should handle array results from searchWeb", async () => {
517
+ mockGrab.mockResolvedValueOnce(`
518
+ <article class="result">
519
+ <h3><a href="https://example.com">Test</a></h3>
520
+ <p class="content">Content</p>
521
+ </article>
522
+ `);
523
+
524
+ const result = await searchSearxng("test", { categories: ["general"] });
525
+
526
+ expect(result.results).toHaveLength(1);
527
+ expect(result.suggestions).toEqual([]);
528
+ });
529
+ });
@@ -0,0 +1,433 @@
1
+ /**
2
+ * @module search-web-api/public-searxng
3
+ * @description SearXNG metasearch via public or private instances.
4
+ */
5
+ import { getDomainWithoutSuffix } from "tldts";
6
+ import { parseDate } from "chrono-node";
7
+ import grab from "grab-url";
8
+
9
+ /**
10
+ * Search Web via SearXNG metasearch of all major search engines.
11
+ */
12
+ export async function searchWeb(
13
+ query: string,
14
+ options: SearchOptions = {},
15
+ ): Promise<SearxngSearchResult[] | SearchResponse> {
16
+ const {
17
+ category = "general",
18
+ recency,
19
+ privateSearxng = null,
20
+ maxRetries = 3,
21
+ page = 1,
22
+ safesearch = false,
23
+ lang = "en-US",
24
+ proxy = null,
25
+ } = options;
26
+
27
+ const CATEGORY_LIST = [
28
+ "general",
29
+ "news",
30
+ "videos",
31
+ "images",
32
+ "science",
33
+ "it",
34
+ "files",
35
+ "social+media",
36
+ ];
37
+ const RECENCY_ALLOWED_LIST = ["day", "week", "month", "year"];
38
+
39
+ const SEARX_DOMAINS = [
40
+ "baresearch.org",
41
+ "copp.gg",
42
+ "darmarit.org",
43
+ "etsi.me",
44
+ "fairsuch.net",
45
+ "nogoo.me",
46
+ "northboot.xyz",
47
+ "nyc1.sx.ggtyler.dev",
48
+ "ooglester.com",
49
+ "opnxng.com",
50
+ "paulgo.io",
51
+ "priv.au",
52
+ "s.trung.fun",
53
+ "search.blitzw.in",
54
+ "search.charliewhiskey.net",
55
+ "search.citw.lgbt",
56
+ "search.darkness.services",
57
+ "search.datura.network",
58
+ "search.dotone.nl",
59
+ "search.gcomm.ch",
60
+ "search.hbubli.cc",
61
+ "search.im-in.space",
62
+ "search.incogniweb.net",
63
+ "search.inetol.net",
64
+ "search.leptons.xyz",
65
+ "search.nadeko.net",
66
+ "search.ngn.tf",
67
+ "search.ononoki.org",
68
+ "search.privacyredirect.com",
69
+ "search.sapti.me",
70
+ "search.rowie.at",
71
+ "search.projectsegfau.lt",
72
+ "search.tommy-tran.com",
73
+ "searx.aleteoryx.me",
74
+ "searx.ankha.ac",
75
+ "searx.be",
76
+ "searx.colbster937.dev",
77
+ "searx.daetalytica.io",
78
+ "searx.dresden.network",
79
+ "searx.foss.family",
80
+ "searx.hu",
81
+ "searx.juancord.xyz",
82
+ "searx.lunar.icu",
83
+ "searx.mxchange.org",
84
+ "searx.namejeff.xyz",
85
+ "searx.oakleycord.dev",
86
+ "searx.ro",
87
+ "searx.sev.monster",
88
+ "searx.thefloatinglab.world",
89
+ "searx.tiekoetter.com",
90
+ "searx.tuxcloud.net",
91
+ "searx.work",
92
+ "searx.zhenyapav.com",
93
+ "searxng.hweeren.com",
94
+ "searxng.online",
95
+ "searxng.shreven.org",
96
+ "searxng.site",
97
+ "skyrimhater.com",
98
+ "sx.ca.zorby.top",
99
+ "sx.catgirl.cloud",
100
+ "sx.thatxtreme.dev",
101
+ "sx.zorby.top",
102
+ "xo.wtf",
103
+ ];
104
+
105
+ //select a random domain if none is provided
106
+ const searchDomain =
107
+ privateSearxng ||
108
+ "https://" +
109
+ SEARX_DOMAINS[Math.floor(Math.random() * SEARX_DOMAINS.length)];
110
+
111
+ const categoryName = category === "tech" ? "it" : category;
112
+
113
+ let url = `${searchDomain}/search`;
114
+
115
+ if (privateSearxng) url += "&format=json";
116
+
117
+ //on cloudflare to avoid "Too many redirects" change SSL mode to Full
118
+ if (proxy && !privateSearxng) url = proxy + url;
119
+
120
+ let resultHTML: any;
121
+ try {
122
+ const params: Record<string, any> = {
123
+ q: encodeURIComponent(query),
124
+ ["category_" + categoryName]: 1,
125
+ language: lang,
126
+ safesearch: safesearch ? "1" : "0",
127
+ pageno: page,
128
+ headers: {
129
+ "accept-language": lang + ",en;q=0.9",
130
+ },
131
+ };
132
+ if (privateSearxng) params.format = "json";
133
+ if (recency && RECENCY_ALLOWED_LIST.includes(recency)) params.time_range = recency;
134
+
135
+ resultHTML = await grab(searchDomain + "/search", params);
136
+ } catch (error: any) {
137
+ const errorMsg = error instanceof Error ? error.message : String(error);
138
+ console.warn(`[searchWeb] Failed to fetch from SearXNG domain "${searchDomain}": ${errorMsg}`);
139
+ if (maxRetries > 0) {
140
+ console.log(`[searchWeb] Retrying with another instance... (${maxRetries} retries left)`);
141
+ return await searchWeb(query, {
142
+ ...options,
143
+ maxRetries: maxRetries - 1,
144
+ });
145
+ }
146
+ console.error(`[searchWeb] All retries exhausted. Returning empty results.`);
147
+ return [];
148
+ }
149
+
150
+ if (privateSearxng) {
151
+ let parsedData: any;
152
+
153
+ // Check if resultHTML is already an object (grab-url auto-parsed JSON)
154
+ if (typeof resultHTML === "object" && resultHTML !== null) {
155
+ parsedData = resultHTML;
156
+ } else if (typeof resultHTML === "string") {
157
+ // It's a string, try to parse it
158
+ if (!resultHTML.startsWith("{")) {
159
+ console.warn(
160
+ "Private SearXNG instance did not return valid JSON, falling back or returning empty",
161
+ );
162
+ return { results: [], suggestions: [], infoboxes: [] };
163
+ }
164
+
165
+ try {
166
+ parsedData = JSON.parse(resultHTML);
167
+ } catch (e) {
168
+ console.error("Failed to parse JSON from private instance", e);
169
+ return { results: [], suggestions: [], infoboxes: [] };
170
+ }
171
+ } else {
172
+ console.error("Unexpected resultHTML type:", typeof resultHTML);
173
+ return { results: [], suggestions: [], infoboxes: [] };
174
+ }
175
+
176
+ let { results, suggestions, infoboxes } = parsedData;
177
+
178
+ results = results.map((result: any) => {
179
+ let title = result.title.replace(/<\/?[^>]+(>|$)/g, "");
180
+
181
+ const TITLE_SPLITTERS_RE = /( [|\-\/:\u00bb] )|( - )|(\|)/;
182
+
183
+ if (TITLE_SPLITTERS_RE.test(title)) {
184
+ const splitTitle = title.split(TITLE_SPLITTERS_RE);
185
+ // Handle breadcrumbed titles
186
+ if (splitTitle.length >= 2) {
187
+ const longestPart = splitTitle.reduce(
188
+ (acc: string, part: string) =>
189
+ part?.length > acc?.length ? part : acc,
190
+ "",
191
+ );
192
+ if (longestPart.length > 10) {
193
+ title = longestPart;
194
+ }
195
+ }
196
+ }
197
+
198
+ title = convertURLSafeHTMLToHTML(title);
199
+ let urlPtr = result.url.replace(/&amp;/g, "&");
200
+
201
+ // Validate URL has path component, not just domain
202
+ try {
203
+ const parsedUrl = new URL(urlPtr);
204
+ // If URL is just domain (path is just "/"), log warning
205
+ if (parsedUrl.pathname === "/" || parsedUrl.pathname === "") {
206
+ console.warn(`[searchWeb] Result URL is domain-only: ${urlPtr}. Full result:`, JSON.stringify(result).slice(0, 200));
207
+ }
208
+ } catch (e) {
209
+ console.error(`[searchWeb] Invalid URL in result: ${urlPtr}`);
210
+ }
211
+
212
+ const snippet = result.content?.replace(/<\/?[^>]+(>|$)/g, "");
213
+ const thumbnail = result.thumbnail;
214
+ const score = Math.round(result.score * 100) / 100;
215
+
216
+ const domain = result.url
217
+ ?.replace(/(http:\/\/|https:\/\/|www.)/gi, "")
218
+ .split("/")[0];
219
+
220
+ let date: string | undefined = undefined;
221
+ let source: string | undefined = undefined;
222
+
223
+ if (typeof result.metadata === "string") {
224
+ const parts = result.metadata.split("|").map((s: string) => s.trim());
225
+ if (parts.length > 1) {
226
+ // Basic check
227
+ const dateObj = parseDate(result.metadata);
228
+ date = dateObj ? dateObj.toISOString().split("T")[0] : undefined;
229
+ const sourcePart = parts[1]; // assuming second part might be source
230
+ source = sourcePart || null;
231
+ }
232
+ }
233
+
234
+ if (!source && domain) {
235
+ source =
236
+ getDomainWithoutSuffix(domain)?.replace(/\b\w/g, (l) =>
237
+ l.toUpperCase(),
238
+ ) || undefined;
239
+ if (source && source.length < 5) source = source.toUpperCase();
240
+ }
241
+
242
+ const favicon = `https://s2.googleusercontent.com/s2/favicons?domain_url=${result.url}`;
243
+ // const favicon =
244
+ // "https://www.google.com/s2/favicons?domain=" +
245
+ // result.url.match(
246
+ // /^(?:https?:\/\/)?(?:www\.)?([^/:?\s]+)(?:[/:?]|$)/i,
247
+ // )?.[0] +
248
+ // "&sz=16";
249
+
250
+ return {
251
+ title,
252
+ url: urlPtr,
253
+ snippet,
254
+ score,
255
+ ...(date ? { date } : {}),
256
+ ...(source ? { source } : {}),
257
+ domain,
258
+ favicon,
259
+ // Compatibility fields
260
+ content: snippet,
261
+ thumbnail,
262
+ ...(result.img_src ? { img_src: result.img_src } : {}),
263
+ ...(result.iframe_src ? { iframe_src: result.iframe_src } : {}),
264
+ };
265
+ });
266
+ return { results, suggestions: suggestions || [], infoboxes };
267
+ }
268
+
269
+ // Public instance scraping (HTML parsing)
270
+ let results: SearxngSearchResult[] = [];
271
+ const resultRegex = /<article class="result[^>]*>[\s\S]*?<\/article>/g;
272
+ const titleUrlRegex = /<h3><a href="([^"]*)"[^>]*>(.*?)<\/a><\/h3>/;
273
+ const snippetRegex = /<p class="content">\s*(.*?)\s*<\/p>/;
274
+
275
+ // Unused in current logic but kept from original code for potential future use or completeness
276
+ // const enginesRegex = /<span>(bing|duckduckgo|yahoo|google)<\/span>/g;
277
+ // const linksRegex = /<a href="([^"]*)" class="(cache_link|proxyfied_link)"[^>]*>(cached|proxied)<\/a>/g;
278
+
279
+ let match;
280
+ while ((match = resultRegex.exec(resultHTML)) !== null) {
281
+ const resultHtml = match[0];
282
+ const titleUrlMatch = titleUrlRegex.exec(resultHtml);
283
+ const snippetMatch = snippetRegex.exec(resultHtml);
284
+
285
+ if (titleUrlMatch && titleUrlMatch[1] && titleUrlMatch[2]) {
286
+ // const urlFound = convertURLSafeHTMLToHTML(titleUrlMatch[1]); // Not used in original, seemingly
287
+ let title = titleUrlMatch[2].replace(/<\/?[^>]+(>|$)/g, "");
288
+ let snippet = snippetMatch
289
+ ? snippetMatch[1].replace(/<\/?[^>]+(>|$)/g, "")
290
+ : "";
291
+
292
+ title = convertURLSafeHTMLToHTML(title);
293
+ snippet = convertURLSafeHTMLToHTML(snippet);
294
+ const urlClean = convertURLSafeHTMLToHTML(titleUrlMatch[1]);
295
+
296
+ // Validate URL has path component, not just domain
297
+ try {
298
+ const parsedUrl = new URL(urlClean);
299
+ if (parsedUrl.pathname === "/" || parsedUrl.pathname === "") {
300
+ console.warn(`[searchWeb] Public scrape URL is domain-only: ${urlClean}`);
301
+ }
302
+ } catch (e) {
303
+ console.error(`[searchWeb] Invalid URL in public scrape: ${urlClean}`);
304
+ }
305
+
306
+ results.push({
307
+ title,
308
+ url: urlClean,
309
+ snippet,
310
+ content: snippet, // Compatibility
311
+ });
312
+ }
313
+ }
314
+
315
+ if (results.length === 0 && maxRetries > 0) {
316
+ return (await searchWeb(query, {
317
+ ...options,
318
+ maxRetries: maxRetries - 1,
319
+ useProxy: true,
320
+ })) as SearxngSearchResult[];
321
+ }
322
+
323
+ results = results.map((result) => {
324
+ const match = result.url.match(
325
+ /^(?:https?:\/\/)?(?:www\.)?([^/:?\s]+)(?:[/:?]|$)/i,
326
+ );
327
+ const domainStr = match ? match[0] : "";
328
+
329
+ const favicon = "https://www.google.com/s2/favicons?domain=" + domainStr;
330
+
331
+ const domain = result.url
332
+ ?.replace(/(http:\/\/|https:\/\/|www.)/gi, "")
333
+ .split("/")[0];
334
+
335
+ return {
336
+ ...result,
337
+ domain,
338
+ favicon,
339
+ thumbnail: favicon, // Compatibility
340
+ };
341
+ });
342
+
343
+ return results;
344
+ }
345
+
346
+ // Wrapper to match existing `searchSearxng` signature if needed elsewhere,
347
+ // OR the user might want this to be the primary `searchWeb` and we just export `searchSearxng` that calls it.
348
+ // The user's request showed `searchWeb` being imported.
349
+ // But the application likely calls `searchSearxng`. Let's reimplement `searchSearxng` to use `searchWeb`.
350
+
351
+ interface SearxngSearchOptions {
352
+ categories?: string[];
353
+ engines?: string[];
354
+ language?: string;
355
+ pageno?: number;
356
+ }
357
+
358
+ export const searchSearxng = async (
359
+ query: string,
360
+ opts?: SearxngSearchOptions,
361
+ ): Promise<{ results: SearxngSearchResult[]; suggestions: string[] }> => {
362
+ // Adapter to call the new searchWeb
363
+ const category = opts?.categories?.[0] || "general"; // simplistic mapping
364
+ const page = opts?.pageno || 1;
365
+ const lang = opts?.language || "en-US";
366
+
367
+ const result = await searchWeb(query, {
368
+ category,
369
+ page,
370
+ lang,
371
+ // privateSearxng: true // or false? The user code said "use custom or false to use the public instances"
372
+ // Let's rely on the default behavior or what `searchWeb` does.
373
+ // However, `searchWeb` logic branches on `privateSearxng` significantly.
374
+ // If we want JSON, we probably want `privateSearxng` set to a domain if we have one, or handle the array return.
375
+
376
+ // IMPORTANT: The user code's `GET` handler passes `privateSearxng: publicInstances ? false : searxngDomain`.
377
+ // where `searxngDomain` was imported from `customize-site`.
378
+ // Since we don't have that file, we used empty string defaults.
379
+ // If `searxngDomain` is falsy, `privateSearxng` becomes falsy (or we should be careful).
380
+ });
381
+
382
+ if (Array.isArray(result)) {
383
+ return { results: result, suggestions: [] };
384
+ } else {
385
+ return { results: result.results, suggestions: result.suggestions || [] };
386
+ }
387
+ };
388
+
389
+ // Helper function to decode HTML entities
390
+ function convertURLSafeHTMLToHTML(html: string): string {
391
+ if (!html) return "";
392
+ return html
393
+ .replace(/&amp;/g, "&")
394
+ .replace(/&lt;/g, "<")
395
+ .replace(/&gt;/g, ">")
396
+ .replace(/&quot;/g, '"')
397
+ .replace(/&#39;/g, "'");
398
+ }
399
+
400
+ interface SearchOptions {
401
+ category?: string | number;
402
+ recency?: string;
403
+ privateSearxng?: string | boolean | null;
404
+ maxRetries?: number;
405
+ page?: number;
406
+ safesearch?: boolean;
407
+ lang?: string;
408
+ proxy?: string | null;
409
+ useProxy?: boolean;
410
+ }
411
+
412
+ export interface SearxngSearchResult {
413
+ title: string;
414
+ url: string;
415
+ snippet?: string;
416
+ domain?: string;
417
+ favicon?: string;
418
+ score?: number;
419
+ source?: string;
420
+ date?: string;
421
+ img_src?: string; // Added for compatibility with existing interfaces
422
+ thumbnail_src?: string; // Added for compatibility
423
+ thumbnail?: string; // Added for compatibility
424
+ content?: string; // Added for compatibility
425
+ author?: string; // Added for compatibility
426
+ iframe_src?: string; // Added for compatibility
427
+ }
428
+
429
+ export interface SearchResponse {
430
+ results: SearxngSearchResult[];
431
+ suggestions: string[];
432
+ infoboxes?: any[];
433
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * @module search-web-api/tavily
3
+ * @description Tavily search API integration.
4
+ */
5
+
6
+ interface TavilySearchOptions {
7
+ searchDepth?: "basic" | "advanced";
8
+ maxResults?: number;
9
+ includeDomains?: string[];
10
+ excludeDomains?: string[];
11
+ }
12
+
13
+ interface TavilySearchResult {
14
+ title: string;
15
+ url: string;
16
+ content: string;
17
+ score: number;
18
+ raw_content?: string;
19
+ }
20
+
21
+ interface TavilyResponse {
22
+ results: TavilySearchResult[];
23
+ query: string;
24
+ }
25
+
26
+ export const searchTavily = async (
27
+ query: string,
28
+ opts?: TavilySearchOptions,
29
+ ): Promise<{ results: TavilySearchResult[]; suggestions: string[] }> => {
30
+ const tavilyApiKey =
31
+ (typeof process !== "undefined" ? process.env.TAVILY_API_KEY : "") || "";
32
+
33
+ if (!tavilyApiKey) {
34
+ throw new Error(
35
+ "Tavily API key not configured. Set TAVILY_API_KEY environment variable.",
36
+ );
37
+ }
38
+
39
+ let sanitizedQuery = query.trim();
40
+ if (sanitizedQuery.length > 400) {
41
+ console.warn(`[Tavily] Query too long (${sanitizedQuery.length} chars), truncating to 400 chars`);
42
+ sanitizedQuery = sanitizedQuery.slice(0, 400);
43
+ }
44
+
45
+ try {
46
+ const response = await fetch("https://api.tavily.com/search", {
47
+ method: "POST",
48
+ headers: { "Content-Type": "application/json" },
49
+ body: JSON.stringify({
50
+ api_key: tavilyApiKey,
51
+ query: sanitizedQuery,
52
+ search_depth: opts?.searchDepth || "basic",
53
+ max_results: opts?.maxResults || 10,
54
+ include_domains: opts?.includeDomains || [],
55
+ exclude_domains: opts?.excludeDomains || [],
56
+ include_answer: false,
57
+ include_raw_content: false,
58
+ }),
59
+ });
60
+
61
+ if (!response.ok) {
62
+ const errorBody = await response.text();
63
+ throw new Error(`HTTP ${response.status}: ${errorBody}`);
64
+ }
65
+
66
+ const data = (await response.json()) as TavilyResponse;
67
+ const results = data.results || [];
68
+
69
+ return {
70
+ results: results.map((r) => ({
71
+ title: r.title,
72
+ url: r.url,
73
+ content: r.content,
74
+ score: r.score,
75
+ raw_content: r.raw_content,
76
+ })),
77
+ suggestions: [],
78
+ };
79
+ } catch (error: any) {
80
+ console.error("Tavily search error:", error);
81
+
82
+ if (error.message?.includes("400")) {
83
+ console.error("Tavily 400 error - Query length:", sanitizedQuery.length);
84
+ console.error("Tavily 400 error - Query preview:", sanitizedQuery.slice(0, 200));
85
+ }
86
+
87
+ throw new Error(
88
+ `Tavily search failed: ${error.message}`,
89
+ );
90
+ }
91
+ };
92
+
93
+ export const getTavilyApiKey = () =>
94
+ (typeof process !== "undefined" ? process.env.TAVILY_API_KEY : "") || "";
95
+
96
+ export const isTavilyConfigured = () => {
97
+ const apiKey = getTavilyApiKey();
98
+ return apiKey && apiKey.length > 0;
99
+ };