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.
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/category-registry.ts +1 -1
- package/src/constants.ts +1 -1
- package/src/engine-descriptions.ts +1 -1
- package/src/engine-status.ts +1 -1
- package/src/engine.ts +1 -1
- package/src/registry/search-engine-category-registry.ts +2 -2
- package/src/registry/search-engine-status-tracker.ts +1 -1
- package/src/result-container.ts +1 -1
- package/src/search/__tests__/public-searxng.test.ts +176 -1
- package/src/search/public-searxng.ts +136 -33
- package/src/search/search-engines-registry-list.ts +74 -74
- package/src/search/search-query-executor.ts +5 -5
- package/src/search/search-result-container.ts +2 -2
- package/src/search-web-types.ts +1 -1
- package/src/search.ts +2 -2
- package/src/sources/academic/arxiv.ts +1 -1
- package/src/sources/academic/core.ts +1 -1
- package/src/sources/academic/crossref.ts +1 -1
- package/src/sources/academic/doaj.ts +1 -1
- package/src/sources/academic/google_scholar.ts +1 -1
- package/src/sources/academic/openalex.ts +1 -1
- package/src/sources/academic/pubmed.ts +1 -1
- package/src/sources/academic/semantic_scholar.ts +1 -1
- package/src/sources/academic/wikidata.ts +1 -1
- package/src/sources/general/baidu.ts +1 -1
- package/src/sources/general/bing.ts +1 -1
- package/src/sources/general/brave.ts +1 -1
- package/src/sources/general/duckduckgo.ts +1 -1
- package/src/sources/general/google.ts +1 -1
- package/src/sources/general/mojeek.ts +1 -1
- package/src/sources/general/qwant.ts +1 -1
- package/src/sources/general/startpage.ts +1 -1
- package/src/sources/general/yahoo.ts +1 -1
- package/src/sources/general/yandex.ts +1 -1
- package/src/sources/images/bing_images.ts +1 -1
- package/src/sources/images/deviantart.ts +1 -1
- package/src/sources/images/flickr.ts +1 -1
- package/src/sources/images/google_images.ts +1 -1
- package/src/sources/images/imgur.ts +1 -1
- package/src/sources/images/openclipart.ts +1 -1
- package/src/sources/images/pixabay.ts +1 -1
- package/src/sources/images/unsplash.ts +1 -1
- package/src/sources/images/wallhaven.ts +1 -1
- package/src/sources/it/crates.ts +1 -1
- package/src/sources/it/dockerhub.ts +1 -1
- package/src/sources/it/github.ts +1 -1
- package/src/sources/it/gitlab.ts +1 -1
- package/src/sources/it/npm.ts +1 -1
- package/src/sources/it/packagist.ts +1 -1
- package/src/sources/it/pypi.ts +1 -1
- package/src/sources/it/rubygems.ts +1 -1
- package/src/sources/it/stackoverflow.ts +1 -1
- package/src/sources/maps/apple_maps.ts +1 -1
- package/src/sources/maps/openstreetmap.ts +1 -1
- package/src/sources/maps/photon.ts +1 -1
- package/src/sources/news/bing_news.ts +1 -1
- package/src/sources/news/google_news.ts +1 -1
- package/src/sources/news/hackernews.ts +1 -1
- package/src/sources/news/yahoo_news.ts +1 -1
- package/src/sources/shopping/ebay.ts +1 -1
- package/src/sources/social/mastodon.ts +1 -1
- package/src/sources/social/medium.ts +1 -1
- package/src/sources/social/reddit.ts +1 -1
- package/src/sources/social/soundcloud.ts +1 -1
- package/src/sources/social/twitter.ts +1 -1
- package/src/sources/specialized/annas_archive.ts +1 -1
- package/src/sources/specialized/archive.ts +1 -1
- package/src/sources/specialized/genius.ts +1 -1
- package/src/sources/specialized/goodreads.ts +1 -1
- package/src/sources/specialized/imdb.ts +1 -1
- package/src/sources/specialized/openlibrary.ts +1 -1
- package/src/sources/specialized/wikipedia.ts +1 -1
- package/src/sources/specialized/wttr.ts +1 -1
- package/src/sources/torrents/1337x.ts +1 -1
- package/src/sources/torrents/eztv.ts +1 -1
- package/src/sources/torrents/kickass.ts +1 -1
- package/src/sources/torrents/nyaa.ts +1 -1
- package/src/sources/torrents/solidtorrents.ts +1 -1
- package/src/sources/torrents/thepiratebay.ts +1 -1
- package/src/sources/torrents/yts.ts +1 -1
- package/src/sources/videos/bing_videos.ts +1 -1
- package/src/sources/videos/dailymotion.ts +1 -1
- package/src/sources/videos/invidious.ts +1 -1
- package/src/sources/videos/peertube.ts +1 -1
- package/src/sources/videos/vimeo.ts +1 -1
- package/src/sources/videos/youtube.ts +1 -1
- 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
package/src/category-registry.ts
CHANGED
package/src/constants.ts
CHANGED
package/src/engine-status.ts
CHANGED
package/src/engine.ts
CHANGED
|
@@ -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
|
|
9
|
-
import { CategoryConfig } from "../types/search-result-types
|
|
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
|
|
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();
|
package/src/result-container.ts
CHANGED
|
@@ -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
|
|
118
|
+
let rawResponse: any;
|
|
121
119
|
try {
|
|
122
120
|
const params: Record<string, any> = {
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
194
|
+
const { suggestions, infoboxes } = parsedData;
|
|
177
195
|
|
|
178
|
-
|
|
179
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 "";
|