From 52a9a785c10f404a3300421bf5ca60beebc9a633 Mon Sep 17 00:00:00 2001 From: matevip Date: Fri, 24 Apr 2026 22:08:42 +0800 Subject: [PATCH] fix(search): bundled SearXNG sidecar actually works out of the box --- .env.example | 4 ++ docker-compose.yml | 14 ++++++- .../tool/search/SearXNGSearchProvider.java | 42 +++++++++++++++---- 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index 4b9d24e8..9561c692 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,10 @@ JWT_SECRET= # 若留空,服务器会允许所有 origin 并在启动日志里 WARN。生产部署务必设置。 MATECLAW_CORS_ALLOWED_ORIGINS= +# SearXNG 会话密钥(容器内部用,留空会用开发默认值)。生产部署请设成 32+ 位随机串。 +# openssl rand -hex 32 +SEARXNG_SECRET= + # ==================== 浏览器工具(可选) ==================== # # Docker 镜像已经把 Chromium 打进去了,默认零配置可用。 diff --git a/docker-compose.yml b/docker-compose.yml index 9bd2b24f..82481181 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,18 +31,30 @@ services: timeout: 5s retries: 5 - # SearXNG 搜索引擎(keyless 搜索 provider,零配置可用) + # SearXNG 搜索引擎(keyless 搜索 provider) + # + # ⚠️ deploy/searxng/settings.yml is bind-mounted because the upstream image + # ships with JSON format disabled and the anti-bot Limiter plugin enabled — + # both of which would silently break mateclaw's SearXNGSearchProvider. + # Do NOT remove that bind mount. searxng: image: searxng/searxng:latest container_name: mateclaw-searxng restart: unless-stopped environment: - SEARXNG_BASE_URL=http://searxng:8080 + - SEARXNG_SECRET=${SEARXNG_SECRET:-mateclaw-dev-searxng-secret-change-me} + - UWSGI_WORKERS=2 + - UWSGI_THREADS=4 volumes: - searxng_data:/etc/searxng + # This override MUST be mounted AFTER the named volume so it shadows + # the image's default settings.yml (enables JSON, disables limiter). + - ./deploy/searxng/settings.yml:/etc/searxng/settings.yml:ro ports: - "8088:8080" healthcheck: + # Healthz needs json format, so this also doubles as an integration check. test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/healthz"] interval: 30s timeout: 5s diff --git a/mateclaw-server/src/main/java/vip/mate/tool/search/SearXNGSearchProvider.java b/mateclaw-server/src/main/java/vip/mate/tool/search/SearXNGSearchProvider.java index ec3c73c1..aa05211e 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/search/SearXNGSearchProvider.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/search/SearXNGSearchProvider.java @@ -81,24 +81,42 @@ public class SearXNGSearchProvider implements SearchProvider { urlBuilder.append("&time_range=").append(searchQuery.freshness().toLowerCase()); } - String response = HttpUtil.createGet(urlBuilder.toString()) + var resp = HttpUtil.createGet(urlBuilder.toString()) .header("Accept", "application/json") .timeout(15000) - .execute() - .body(); + .execute(); + int status = resp.getStatus(); + String response = resp.body(); + String contentType = resp.header("Content-Type"); - log.debug("SearXNG result for '{}': length={}", searchQuery.query(), response != null ? response.length() : 0); - return parseResponse(response, searchQuery.resolvedCount()); + log.debug("SearXNG response for '{}': status={}, contentType={}, length={}", + searchQuery.query(), status, contentType, response != null ? response.length() : 0); + return parseResponse(response, status, contentType, searchQuery.resolvedCount(), urlBuilder.toString()); } - private List parseResponse(String response, int limit) { + private List parseResponse(String response, int status, String contentType, + int limit, String requestUrl) { List results = new ArrayList<>(); - if (response == null || response.isBlank()) return results; + if (response == null || response.isBlank()) { + log.warn("SearXNG returned empty body (status={}, url={})", status, requestUrl); + return results; + } + if (status >= 400) { + log.warn("SearXNG returned HTTP {} — preview: {}", status, preview(response)); + return results; + } + if (contentType != null && !contentType.contains("json")) { + // Most common cause: settings.yml has no `json` under search.formats + // or the Limiter plugin rewrote the response to HTML. + log.warn("SearXNG did not return JSON (contentType={}). Check settings.yml has search.formats including 'json' and server.limiter: false. Preview: {}", + contentType, preview(response)); + return results; + } try { JSONObject json = JSONUtil.parseObj(response); JSONArray items = json.getJSONArray("results"); - if (items == null) return results; + if (items == null || items.isEmpty()) return results; limit = Math.min(items.size(), limit); for (int i = 0; i < limit; i++) { @@ -114,11 +132,17 @@ public class SearXNGSearchProvider implements SearchProvider { .build()); } } catch (Exception e) { - log.warn("SearXNG 结果解析失败: {}", e.getMessage()); + log.warn("SearXNG parse failed: {} — preview: {}", e.getMessage(), preview(response)); } return results; } + private static String preview(String body) { + if (body == null) return ""; + String flat = body.replaceAll("\\s+", " ").trim(); + return flat.length() > 200 ? flat.substring(0, 200) + "..." : flat; + } + private String extractDomain(String url) { try { return URI.create(url).getHost();