From ad85e5c64ca0d93302c920d3cfedc1c9b05a67a3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 06:56:20 +0100 Subject: [PATCH] feat(searxng): pass through image result urls --- CHANGELOG.md | 1 + docs/tools/searxng-search.md | 2 + extensions/searxng/src/searxng-client.test.ts | 47 +++++++++++++++++++ extensions/searxng/src/searxng-client.ts | 10 +++- 4 files changed, 59 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04010308c61..cdcf5b03178 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai - Plugin SDK: re-export `isPrivateIpAddress` from `plugin-sdk/ssrf-runtime`, restoring source-checkout builds for SearXNG and Firecrawl private-network guards. Thanks @vincentkoc. - CLI/directory: report unsupported directory operations for installed channel plugins instead of prompting to reinstall the plugin when it lacks a directory adapter. Fixes #75770. Thanks @lawong888. - Web search/SearXNG: show the JSON API `search.formats` prerequisite during SearXNG setup before prompting for the base URL. Supersedes #65592. Thanks @evanpaul14. +- Web search/SearXNG: pass through `img_src` image URLs from SearXNG image-category results. Supersedes #61416. Thanks @sghael. - Web search: keep public provider requests on the strict SSRF guard and reserve private-network access for explicit self-hosted SearXNG/Firecrawl endpoints. Fixes #74357 and supersedes #74360. Thanks @fede-kamel. - Web search/Firecrawl: allow self-hosted private/internal Firecrawl `baseUrl` endpoints, including HTTP for private targets, while keeping hosted Firecrawl on the strict official endpoint. Fixes #63877 and supersedes #59666, #63941, and #74013. Thanks @jhthompson12, @jzakirov, @Mlightsnow, and @shad0wca7. - Providers/OpenRouter: strip trailing assistant prefill turns from verified OpenRouter Anthropic model requests when reasoning is enabled, so Claude 4.6 routes no longer fail with Anthropic's prefill rejection through the OpenAI-compatible adapter. Fixes #75395. Thanks @sbmilburn. diff --git a/docs/tools/searxng-search.md b/docs/tools/searxng-search.md index 2052055326b..9944676f627 100644 --- a/docs/tools/searxng-search.md +++ b/docs/tools/searxng-search.md @@ -112,6 +112,8 @@ key wins first). ## Notes - **JSON API** -- uses SearXNG's native `format=json` endpoint, not HTML scraping +- **Image result URLs** -- image-category results include `img_src` when SearXNG + returns a direct image URL - **No API key** -- works with any SearXNG instance out of the box - **Base URL validation** -- `baseUrl` must be a valid `http://` or `https://` URL; public hosts must use `https://` diff --git a/extensions/searxng/src/searxng-client.test.ts b/extensions/searxng/src/searxng-client.test.ts index d6aa090ae23..7d841def6d4 100644 --- a/extensions/searxng/src/searxng-client.test.ts +++ b/extensions/searxng/src/searxng-client.test.ts @@ -39,6 +39,53 @@ describe("searxng client", () => { ).toEqual([{ title: "One", url: "https://example.com/1", content: "A" }]); }); + it("preserves img_src from image search results", () => { + expect( + __testing.parseSearxngResponseText( + JSON.stringify({ + results: [ + { + title: "Kitten", + url: "https://example.com/kitten", + content: "A cute kitten", + img_src: "https://cdn.example.com/kitten.jpg", + }, + { + title: "No Image", + url: "https://example.com/text", + content: "Text only", + }, + { + title: "Bad Image", + url: "https://example.com/bad", + img_src: { url: "https://cdn.example.com/bad.jpg" }, + }, + ], + }), + 10, + ), + ).toEqual([ + { + title: "Kitten", + url: "https://example.com/kitten", + content: "A cute kitten", + img_src: "https://cdn.example.com/kitten.jpg", + }, + { + title: "No Image", + url: "https://example.com/text", + content: "Text only", + img_src: undefined, + }, + { + title: "Bad Image", + url: "https://example.com/bad", + content: undefined, + img_src: undefined, + }, + ]); + }); + it("drops malformed result rows instead of failing the whole response", () => { expect( __testing.parseSearxngResponseText( diff --git a/extensions/searxng/src/searxng-client.ts b/extensions/searxng/src/searxng-client.ts index 0634c5f5de9..0ad17563ca1 100644 --- a/extensions/searxng/src/searxng-client.ts +++ b/extensions/searxng/src/searxng-client.ts @@ -40,6 +40,7 @@ type SearxngResult = { url: string; title: string; content?: string; + img_src?: string; }; type SearxngResponse = { @@ -51,7 +52,12 @@ function normalizeSearxngResult(value: unknown): SearxngResult | null { return null; } - const candidate = value as { url?: unknown; title?: unknown; content?: unknown }; + const candidate = value as { + url?: unknown; + title?: unknown; + content?: unknown; + img_src?: unknown; + }; if (typeof candidate.url !== "string" || typeof candidate.title !== "string") { return null; } @@ -60,6 +66,7 @@ function normalizeSearxngResult(value: unknown): SearxngResult | null { url: candidate.url, title: candidate.title, content: typeof candidate.content === "string" ? candidate.content : undefined, + img_src: typeof candidate.img_src === "string" ? candidate.img_src : undefined, }; } @@ -254,6 +261,7 @@ export async function runSearxngSearch(params: { url: result.url, snippet: result.content ? wrapWebContent(result.content, "web_search") : "", siteName: resolveSiteName(result.url) || undefined, + img_src: result.img_src || undefined, })), } satisfies Record;