search-web-api 1.0.124 → 1.0.126

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.
Files changed (89) hide show
  1. package/README.md +1 -0
  2. package/package.json +1 -1
  3. package/src/category-registry.ts +1 -1
  4. package/src/constants.ts +1 -1
  5. package/src/engine-descriptions.ts +1 -1
  6. package/src/engine-status.ts +1 -1
  7. package/src/engine.ts +1 -1
  8. package/src/registry/search-engine-category-registry.ts +2 -2
  9. package/src/registry/search-engine-status-tracker.ts +1 -1
  10. package/src/result-container.ts +1 -1
  11. package/src/search/__tests__/public-searxng.test.ts +176 -1
  12. package/src/search/public-searxng.ts +136 -33
  13. package/src/search/search-engines-registry-list.ts +74 -74
  14. package/src/search/search-query-executor.ts +5 -5
  15. package/src/search/search-result-container.ts +2 -2
  16. package/src/search-web-types.ts +1 -1
  17. package/src/search.ts +2 -2
  18. package/src/sources/academic/arxiv.ts +1 -1
  19. package/src/sources/academic/core.ts +1 -1
  20. package/src/sources/academic/crossref.ts +1 -1
  21. package/src/sources/academic/doaj.ts +1 -1
  22. package/src/sources/academic/google_scholar.ts +1 -1
  23. package/src/sources/academic/openalex.ts +1 -1
  24. package/src/sources/academic/pubmed.ts +1 -1
  25. package/src/sources/academic/semantic_scholar.ts +1 -1
  26. package/src/sources/academic/wikidata.ts +1 -1
  27. package/src/sources/general/baidu.ts +1 -1
  28. package/src/sources/general/bing.ts +1 -1
  29. package/src/sources/general/brave.ts +1 -1
  30. package/src/sources/general/duckduckgo.ts +1 -1
  31. package/src/sources/general/google.ts +1 -1
  32. package/src/sources/general/mojeek.ts +1 -1
  33. package/src/sources/general/qwant.ts +1 -1
  34. package/src/sources/general/startpage.ts +1 -1
  35. package/src/sources/general/yahoo.ts +1 -1
  36. package/src/sources/general/yandex.ts +1 -1
  37. package/src/sources/images/bing_images.ts +1 -1
  38. package/src/sources/images/deviantart.ts +1 -1
  39. package/src/sources/images/flickr.ts +1 -1
  40. package/src/sources/images/google_images.ts +1 -1
  41. package/src/sources/images/imgur.ts +1 -1
  42. package/src/sources/images/openclipart.ts +1 -1
  43. package/src/sources/images/pixabay.ts +1 -1
  44. package/src/sources/images/unsplash.ts +1 -1
  45. package/src/sources/images/wallhaven.ts +1 -1
  46. package/src/sources/it/crates.ts +1 -1
  47. package/src/sources/it/dockerhub.ts +1 -1
  48. package/src/sources/it/github.ts +1 -1
  49. package/src/sources/it/gitlab.ts +1 -1
  50. package/src/sources/it/npm.ts +1 -1
  51. package/src/sources/it/packagist.ts +1 -1
  52. package/src/sources/it/pypi.ts +1 -1
  53. package/src/sources/it/rubygems.ts +1 -1
  54. package/src/sources/it/stackoverflow.ts +1 -1
  55. package/src/sources/maps/apple_maps.ts +1 -1
  56. package/src/sources/maps/openstreetmap.ts +1 -1
  57. package/src/sources/maps/photon.ts +1 -1
  58. package/src/sources/news/bing_news.ts +1 -1
  59. package/src/sources/news/google_news.ts +1 -1
  60. package/src/sources/news/hackernews.ts +1 -1
  61. package/src/sources/news/yahoo_news.ts +1 -1
  62. package/src/sources/shopping/ebay.ts +1 -1
  63. package/src/sources/social/mastodon.ts +1 -1
  64. package/src/sources/social/medium.ts +1 -1
  65. package/src/sources/social/reddit.ts +1 -1
  66. package/src/sources/social/soundcloud.ts +1 -1
  67. package/src/sources/social/twitter.ts +1 -1
  68. package/src/sources/specialized/annas_archive.ts +1 -1
  69. package/src/sources/specialized/archive.ts +1 -1
  70. package/src/sources/specialized/genius.ts +1 -1
  71. package/src/sources/specialized/goodreads.ts +1 -1
  72. package/src/sources/specialized/imdb.ts +1 -1
  73. package/src/sources/specialized/openlibrary.ts +1 -1
  74. package/src/sources/specialized/wikipedia.ts +1 -1
  75. package/src/sources/specialized/wttr.ts +1 -1
  76. package/src/sources/torrents/1337x.ts +1 -1
  77. package/src/sources/torrents/eztv.ts +1 -1
  78. package/src/sources/torrents/kickass.ts +1 -1
  79. package/src/sources/torrents/nyaa.ts +1 -1
  80. package/src/sources/torrents/solidtorrents.ts +1 -1
  81. package/src/sources/torrents/thepiratebay.ts +1 -1
  82. package/src/sources/torrents/yts.ts +1 -1
  83. package/src/sources/videos/bing_videos.ts +1 -1
  84. package/src/sources/videos/dailymotion.ts +1 -1
  85. package/src/sources/videos/invidious.ts +1 -1
  86. package/src/sources/videos/peertube.ts +1 -1
  87. package/src/sources/videos/vimeo.ts +1 -1
  88. package/src/sources/videos/youtube.ts +1 -1
  89. package/src/types/search-engine-interface.ts +1 -1
package/README.md CHANGED
@@ -26,6 +26,7 @@
26
26
  <a href="https://codespaces.new/vtempest/qwksearch-research-agent">
27
27
  <img src="https://github.com/codespaces/badge.svg" width="150" height="20" />
28
28
  </a>
29
+ <a href="https://codecov.io/gh/OpenSourceAGI/qwksearch-research-agent"><img src="https://codecov.io/gh/OpenSourceAGI/qwksearch-research-agent/graph/badge.svg?component=package-search-web-api" alt="Coverage" /></a>
29
30
  </p>
30
31
 
31
32
 
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.124",
5
+ "version": "1.0.126",
6
6
  "author": "vtempest",
7
7
  "license": "rights.institute/PROSPER",
8
8
  "repository": {
@@ -3,4 +3,4 @@
3
3
  * config map (with per-category scoring weights), and the global
4
4
  * `categoryRegistry` singleton from the canonical registry module.
5
5
  */
6
- export * from "./registry/search-engine-category-registry.js";
6
+ export * from "./registry/search-engine-category-registry";
package/src/constants.ts CHANGED
@@ -3,4 +3,4 @@
3
3
  * config module: CATEGORY_LIST, RECENCY_ALLOWED_LIST, SEARX_DOMAINS, and the
4
4
  * per-category engine directory used for documentation and UI rendering.
5
5
  */
6
- export * from "./config/search-engine-constants.js";
6
+ export * from "./config/search-engine-constants";
@@ -4,4 +4,4 @@
4
4
  * canonical registry module. Descriptions are sourced from SearXNG's
5
5
  * engine_descriptions.json and cover all 75+ supported engines.
6
6
  */
7
- export * from "./registry/search-engine-descriptions.js";
7
+ export * from "./registry/search-engine-descriptions";
@@ -4,4 +4,4 @@
4
4
  * The tracker records per-engine success/failure counts, response times,
5
5
  * and health status, and gates which engines are eligible to run.
6
6
  */
7
- export * from "./registry/search-engine-status-tracker.js";
7
+ export * from "./registry/search-engine-status-tracker";
package/src/engine.ts CHANGED
@@ -3,4 +3,4 @@
3
3
  * and the `extractResponseData` helper from the canonical types module. All
4
4
  * engine source adapters import from `types/search-engine-interface` directly.
5
5
  */
6
- export * from "./types/search-engine-interface.js";
6
+ export * from "./types/search-engine-interface";
@@ -5,8 +5,8 @@
5
5
  * system, it supports multi-category searches and weighted result combination.
6
6
  */
7
7
 
8
- import { Engine } from "../types/search-engine-interface.js";
9
- import { CategoryConfig } from "../types/search-result-types.js";
8
+ import { Engine } from "../types/search-engine-interface";
9
+ import { CategoryConfig } from "../types/search-result-types";
10
10
 
11
11
  export const CATEGORIES: { [key: string]: CategoryConfig } = {
12
12
  general: {
@@ -6,7 +6,7 @@
6
6
  * their success rate exceeds twice their failure count.
7
7
  */
8
8
 
9
- import { EngineStatus, EngineLog } from "../types/search-result-types.js";
9
+ import { EngineStatus, EngineLog } from "../types/search-result-types";
10
10
 
11
11
  export class EngineStatusTracker {
12
12
  private statusMap: Map<string, EngineStatus> = new Map();
@@ -5,4 +5,4 @@
5
5
  * using position-weighted per-engine and per-category multipliers, and groups
6
6
  * the final list by result category.
7
7
  */
8
- export * from "./search/search-result-container.js";
8
+ export * from "./search/search-result-container";
@@ -2,7 +2,7 @@
2
2
  * @fileoverview Unit tests for SearXNG search functionality
3
3
  */
4
4
  import { beforeEach, describe, expect, it, vi, type MockedFunction } from "vitest";
5
- import { searchWeb, searchSearxng } from "../public-searxng";
5
+ import { searchWeb, searchSearxng, normalizeGrabResponse } from "../public-searxng";
6
6
  import grab from "grab-url";
7
7
 
8
8
  // Mock grab-url
@@ -547,3 +547,178 @@ describe("searchSearxng", () => {
547
547
  expect(result.suggestions).toEqual([]);
548
548
  });
549
549
  });
550
+
551
+ describe("grab-url response shapes", () => {
552
+ beforeEach(() => {
553
+ vi.clearAllMocks();
554
+ vi.spyOn(console, "warn").mockImplementation(() => {});
555
+ vi.spyOn(console, "error").mockImplementation(() => {});
556
+ vi.spyOn(console, "log").mockImplementation(() => {});
557
+ });
558
+
559
+ // grab-url resolves with `{ error }` instead of rejecting, so these bodies
560
+ // used to reach `parsedData.results.map` and throw a TypeError, which the
561
+ // API route reported as a 500.
562
+ it("returns an empty response when the private instance errors out", async () => {
563
+ mockGrab.mockResolvedValue({ error: "HTTP error: 429 Too Many Requests" } as any);
564
+
565
+ const result = await searchWeb("dd", {
566
+ privateSearxng: "https://search.example.com",
567
+ page: 2,
568
+ maxRetries: 6,
569
+ });
570
+
571
+ expect(result).toEqual({ results: [], suggestions: [], infoboxes: [] });
572
+ // No point hammering the same private host - the caller falls back.
573
+ expect(mockGrab).toHaveBeenCalledTimes(1);
574
+ });
575
+
576
+ it("returns an empty response when the private instance serves HTML", async () => {
577
+ mockGrab.mockResolvedValue({
578
+ data: "<html><body>Too many requests</body></html>",
579
+ } as any);
580
+
581
+ const result = await searchWeb("dd", {
582
+ privateSearxng: "https://search.example.com",
583
+ page: 2,
584
+ });
585
+
586
+ expect(result).toEqual({ results: [], suggestions: [], infoboxes: [] });
587
+ });
588
+
589
+ it("returns an empty response when the private instance JSON has no results", async () => {
590
+ mockGrab.mockResolvedValue({ isLoading: false, suggestions: [] } as any);
591
+
592
+ const result = await searchWeb("dd", {
593
+ privateSearxng: "https://search.example.com",
594
+ });
595
+
596
+ expect(result).toEqual({ results: [], suggestions: [], infoboxes: [] });
597
+ });
598
+
599
+ it("skips malformed results instead of throwing", async () => {
600
+ mockGrab.mockResolvedValue({
601
+ results: [
602
+ { url: "https://example.com/no-title" },
603
+ { title: "No URL at all" },
604
+ null,
605
+ {
606
+ title: "Good",
607
+ url: "https://example.com/good",
608
+ content: "Snippet",
609
+ score: "not-a-number",
610
+ metadata: "not a date | Source",
611
+ },
612
+ ],
613
+ suggestions: ["one"],
614
+ } as any);
615
+
616
+ const result = await searchWeb("dd", {
617
+ privateSearxng: "https://search.example.com",
618
+ });
619
+
620
+ expect(Array.isArray(result)).toBe(false);
621
+ if (!Array.isArray(result)) {
622
+ expect(result.results).toHaveLength(2);
623
+ expect(result.results[0].title).toBe("");
624
+ expect(result.results[1].title).toBe("Good");
625
+ expect(result.results[1].score).toBeUndefined();
626
+ expect(result.results[1].date).toBeUndefined();
627
+ expect(result.suggestions).toEqual(["one"]);
628
+ }
629
+ });
630
+
631
+ it("scrapes HTML that grab-url returned on the `data` field", async () => {
632
+ mockGrab.mockResolvedValue({
633
+ data: `
634
+ <article class="result">
635
+ <h3><a href="https://example.com/1">Wrapped Result</a></h3>
636
+ <p class="content">Wrapped snippet</p>
637
+ </article>
638
+ `,
639
+ } as any);
640
+
641
+ const result = await searchWeb("test", { privateSearxng: false });
642
+
643
+ expect(Array.isArray(result)).toBe(true);
644
+ if (Array.isArray(result)) {
645
+ expect(result).toHaveLength(1);
646
+ expect(result[0].title).toBe("Wrapped Result");
647
+ expect(result[0].url).toBe("https://example.com/1");
648
+ }
649
+ });
650
+
651
+ it("tries another public instance when one errors out", async () => {
652
+ mockGrab
653
+ .mockResolvedValueOnce({ error: "HTTP error: 502 Bad Gateway" } as any)
654
+ .mockResolvedValueOnce(`
655
+ <article class="result">
656
+ <h3><a href="https://example.com/2">Second Instance</a></h3>
657
+ <p class="content">Snippet</p>
658
+ </article>
659
+ ` as any);
660
+
661
+ const result = await searchWeb("test", { privateSearxng: false, maxRetries: 3 });
662
+
663
+ expect(mockGrab).toHaveBeenCalledTimes(2);
664
+ if (Array.isArray(result)) {
665
+ expect(result[0].title).toBe("Second Instance");
666
+ }
667
+ });
668
+
669
+ it("gives up with an empty array when every public instance errors out", async () => {
670
+ mockGrab.mockResolvedValue({ error: "HTTP error: 502 Bad Gateway" } as any);
671
+
672
+ const result = await searchWeb("test", { privateSearxng: false, maxRetries: 2 });
673
+
674
+ expect(mockGrab).toHaveBeenCalledTimes(3); // initial + 2 retries
675
+ expect(result).toEqual([]);
676
+ });
677
+
678
+ it("passes the query unencoded and disables grab-url HTML parsing", async () => {
679
+ mockGrab.mockResolvedValueOnce({ results: [], suggestions: [] } as any);
680
+
681
+ await searchWeb("olympic games 2028", {
682
+ privateSearxng: "https://search.example.com",
683
+ });
684
+
685
+ // grab-url encodes GET params itself; pre-encoding turned spaces into
686
+ // "%2520" and searched for the literal escape sequence.
687
+ expect(mockGrab).toHaveBeenCalledWith(
688
+ "https://search.example.com/search",
689
+ expect.objectContaining({ q: "olympic games 2028", dom: false }),
690
+ );
691
+ });
692
+ });
693
+
694
+ describe("normalizeGrabResponse", () => {
695
+ it("reports missing and non-object responses as errors", () => {
696
+ expect(normalizeGrabResponse(null).error).toBeTruthy();
697
+ expect(normalizeGrabResponse(undefined).error).toBeTruthy();
698
+ expect(normalizeGrabResponse(42).error).toBeTruthy();
699
+ });
700
+
701
+ it("passes strings through as text", () => {
702
+ expect(normalizeGrabResponse("<html></html>")).toEqual({ text: "<html></html>" });
703
+ });
704
+
705
+ it("surfaces the error field grab-url sets on failure", () => {
706
+ expect(normalizeGrabResponse({ error: "HTTP error: 500" })).toEqual({
707
+ error: "HTTP error: 500",
708
+ });
709
+ });
710
+
711
+ it("treats a root-level results array as JSON", () => {
712
+ const raw = { results: [{ url: "https://example.com" }], isLoading: false };
713
+ expect(normalizeGrabResponse(raw)).toEqual({ json: raw });
714
+ });
715
+
716
+ it("unwraps text and JSON bodies carried on `data`", () => {
717
+ expect(normalizeGrabResponse({ data: "<html></html>" })).toEqual({
718
+ text: "<html></html>",
719
+ });
720
+
721
+ const nested = { results: [{ url: "https://example.com" }] };
722
+ expect(normalizeGrabResponse({ data: nested })).toEqual({ json: nested });
723
+ });
724
+ });
@@ -112,27 +112,33 @@ export async function searchWeb(
112
112
 
113
113
  let url = `${searchDomain}/search`;
114
114
 
115
- if (privateSearxng) url += "&format=json";
116
-
117
115
  //on cloudflare to avoid "Too many redirects" change SSL mode to Full
118
116
  if (proxy && !privateSearxng) url = proxy + url;
119
117
 
120
- let resultHTML: any;
118
+ let rawResponse: any;
121
119
  try {
122
120
  const params: Record<string, any> = {
123
- q: encodeURIComponent(query),
121
+ // grab-url encodes GET params itself, so the query is passed raw here -
122
+ // pre-encoding it would double-encode spaces and symbols.
123
+ q: query,
124
124
  ["category_" + categoryName]: 1,
125
125
  language: lang,
126
126
  safesearch: safesearch ? "1" : "0",
127
127
  pageno: page,
128
+ // grab-url parses HTML responses into a DOM by default; the public
129
+ // instance path scrapes raw markup, so keep the body as text.
130
+ dom: false,
128
131
  headers: {
129
132
  "accept-language": lang + ",en;q=0.9",
133
+ accept: privateSearxng
134
+ ? "application/json, text/html;q=0.9"
135
+ : "text/html, application/xhtml+xml",
130
136
  },
131
137
  };
132
138
  if (privateSearxng) params.format = "json";
133
139
  if (recency && RECENCY_ALLOWED_LIST.includes(recency)) params.time_range = recency;
134
140
 
135
- resultHTML = await grab(searchDomain + "/search", params);
141
+ rawResponse = await grab(url, params);
136
142
  } catch (error: any) {
137
143
  const errorMsg = error instanceof Error ? error.message : String(error);
138
144
  console.warn(`[searchWeb] Failed to fetch from SearXNG domain "${searchDomain}": ${errorMsg}`);
@@ -147,36 +153,54 @@ export async function searchWeb(
147
153
  return [];
148
154
  }
149
155
 
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
- }
156
+ // grab-url resolves instead of rejecting when a request fails: network and
157
+ // HTTP errors come back as `{ error }` and non-JSON bodies as `{ data }`.
158
+ // Normalizing here keeps the parsing below from reading `.results` off an
159
+ // error object, which used to throw and surface as a 500 from the API route.
160
+ const response = normalizeGrabResponse(rawResponse);
164
161
 
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);
162
+ if (response.error) {
163
+ console.warn(
164
+ `[searchWeb] SearXNG domain "${searchDomain}" returned an error: ${response.error}`,
165
+ );
166
+
167
+ // Public instances are picked at random, so another one is worth a try.
168
+ // A private instance would just fail the same way; the caller falls back
169
+ // to the public instances instead.
170
+ if (!privateSearxng && maxRetries > 0) {
171
+ return (await searchWeb(query, {
172
+ ...options,
173
+ maxRetries: maxRetries - 1,
174
+ })) as SearxngSearchResult[];
175
+ }
176
+
177
+ return privateSearxng ? { results: [], suggestions: [], infoboxes: [] } : [];
178
+ }
179
+
180
+ if (privateSearxng) {
181
+ const parsedData = response.json ?? parseJSONText(response.text);
182
+
183
+ // A private instance can answer 200 with an HTML error/captcha page, or
184
+ // with JSON that has no `results` (rate limited, format=json disabled).
185
+ // Return an empty response so the caller can fall back to public
186
+ // instances instead of blowing up on `undefined.map`.
187
+ if (!parsedData || !Array.isArray(parsedData.results)) {
188
+ console.warn(
189
+ `[searchWeb] Private SearXNG instance "${searchDomain}" did not return JSON results (page ${page}).`,
190
+ );
173
191
  return { results: [], suggestions: [], infoboxes: [] };
174
192
  }
175
193
 
176
- let { results, suggestions, infoboxes } = parsedData;
194
+ const { suggestions, infoboxes } = parsedData;
177
195
 
178
- results = results.map((result: any) => {
179
- let title = result.title.replace(/<\/?[^>]+(>|$)/g, "");
196
+ // Some engines return entries without a usable link; skip those rather
197
+ // than throwing while normalizing them.
198
+ const usableResults = parsedData.results.filter(
199
+ (result: any) => result && typeof result.url === "string" && result.url,
200
+ );
201
+
202
+ const results = usableResults.map((result: any) => {
203
+ let title = String(result.title ?? "").replace(/<\/?[^>]+(>|$)/g, "");
180
204
 
181
205
  const TITLE_SPLITTERS_RE = /( [|\-\/:\u00bb] )|( - )|(\|)/;
182
206
 
@@ -211,7 +235,9 @@ export async function searchWeb(
211
235
 
212
236
  const snippet = result.content?.replace(/<\/?[^>]+(>|$)/g, "");
213
237
  const thumbnail = result.thumbnail;
214
- const score = Math.round(result.score * 100) / 100;
238
+ const score = Number.isFinite(result.score)
239
+ ? Math.round(result.score * 100) / 100
240
+ : undefined;
215
241
 
216
242
  const domain = result.url
217
243
  ?.replace(/(http:\/\/|https:\/\/|www.)/gi, "")
@@ -225,7 +251,10 @@ export async function searchWeb(
225
251
  if (parts.length > 1) {
226
252
  // Basic check
227
253
  const dateObj = parseDate(result.metadata);
228
- date = dateObj ? dateObj.toISOString().split("T")[0] : undefined;
254
+ date =
255
+ dateObj && !Number.isNaN(dateObj.getTime())
256
+ ? dateObj.toISOString().split("T")[0]
257
+ : undefined;
229
258
  const sourcePart = parts[1]; // assuming second part might be source
230
259
  source = sourcePart || null;
231
260
  }
@@ -263,10 +292,16 @@ export async function searchWeb(
263
292
  ...(result.iframe_src ? { iframe_src: result.iframe_src } : {}),
264
293
  };
265
294
  });
266
- return { results, suggestions: suggestions || [], infoboxes };
295
+
296
+ return {
297
+ results,
298
+ suggestions: suggestions || [],
299
+ infoboxes: infoboxes || [],
300
+ };
267
301
  }
268
302
 
269
303
  // Public instance scraping (HTML parsing)
304
+ const resultHTML = response.text ?? "";
270
305
  let results: SearxngSearchResult[] = [];
271
306
  const resultRegex = /<article class="result[^>]*>[\s\S]*?<\/article>/g;
272
307
  const titleUrlRegex = /<h3><a href="([^"]*)"[^>]*>(.*?)<\/a><\/h3>/;
@@ -386,6 +421,74 @@ export const searchSearxng = async (
386
421
  }
387
422
  };
388
423
 
424
+ /**
425
+ * Normalized view of whatever `grab` handed back.
426
+ * Exactly one of `json`, `text` or `error` is meaningful.
427
+ */
428
+ export interface NormalizedGrabResponse {
429
+ /** Parsed JSON body, when the response was JSON with search results. */
430
+ json?: any;
431
+ /** Raw body text, when the response was HTML or another text format. */
432
+ text?: string;
433
+ /** Message describing why the request did not produce a usable body. */
434
+ error?: string;
435
+ }
436
+
437
+ /**
438
+ * `grab` resolves rather than rejects on failure: network and HTTP errors come
439
+ * back as `{ error }`, JSON bodies are merged onto the root of the response
440
+ * object, and text/binary bodies are placed on `.data`. This flattens those
441
+ * shapes (plus the plain string a raw fetch would return) into one union so
442
+ * callers never read `.results` off an error object.
443
+ *
444
+ * @param raw The value returned by `grab`.
445
+ * @returns The body as JSON or text, or the error that prevented both.
446
+ */
447
+ export function normalizeGrabResponse(raw: any): NormalizedGrabResponse {
448
+ if (raw === null || raw === undefined) return { error: "empty response" };
449
+ if (typeof raw === "string") return { text: raw };
450
+ if (typeof raw !== "object") return { error: `unexpected response type "${typeof raw}"` };
451
+
452
+ if (typeof raw.error === "string" && raw.error) return { error: raw.error };
453
+ if (Array.isArray(raw.results)) return { json: raw };
454
+
455
+ const { data } = raw;
456
+ if (typeof data === "string") return { text: data };
457
+ if (data && typeof data === "object") {
458
+ if (typeof data.error === "string" && data.error) return { error: data.error };
459
+ if (Array.isArray(data.results)) return { json: data };
460
+ }
461
+
462
+ // An object with neither results nor a body: hand it back as JSON and let
463
+ // the caller decide whether it is usable.
464
+ return { json: raw };
465
+ }
466
+
467
+ /**
468
+ * Parse a JSON object out of a response body, tolerating HTML error pages.
469
+ *
470
+ * @param text The raw response body, if there was one.
471
+ * @returns The parsed object, or null when the body was not a JSON object.
472
+ */
473
+ function parseJSONText(text: string | undefined): any {
474
+ if (!text) return null;
475
+
476
+ const trimmed = text.trim();
477
+ if (!trimmed.startsWith("{")) {
478
+ console.warn(
479
+ "Private SearXNG instance did not return valid JSON, falling back or returning empty",
480
+ );
481
+ return null;
482
+ }
483
+
484
+ try {
485
+ return JSON.parse(trimmed);
486
+ } catch (e) {
487
+ console.error("Failed to parse JSON from private instance", e);
488
+ return null;
489
+ }
490
+ }
491
+
389
492
  // Helper function to decode HTML entities
390
493
  function convertURLSafeHTMLToHTML(html: string): string {
391
494
  if (!html) return "";