fix(search): bundled SearXNG sidecar actually works out of the box

This commit is contained in:
matevip 2026-04-24 22:08:42 +08:00
parent 178b9306d9
commit 52a9a785c1
3 changed files with 50 additions and 10 deletions

View File

@ -29,6 +29,10 @@ JWT_SECRET=
# 若留空,服务器会允许所有 origin 并在启动日志里 WARN。生产部署务必设置。 # 若留空,服务器会允许所有 origin 并在启动日志里 WARN。生产部署务必设置。
MATECLAW_CORS_ALLOWED_ORIGINS= MATECLAW_CORS_ALLOWED_ORIGINS=
# SearXNG 会话密钥(容器内部用,留空会用开发默认值)。生产部署请设成 32+ 位随机串。
# openssl rand -hex 32
SEARXNG_SECRET=
# ==================== 浏览器工具(可选) ==================== # ==================== 浏览器工具(可选) ====================
# #
# Docker 镜像已经把 Chromium 打进去了,默认零配置可用。 # Docker 镜像已经把 Chromium 打进去了,默认零配置可用。

View File

@ -31,18 +31,30 @@ services:
timeout: 5s timeout: 5s
retries: 5 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: searxng:
image: searxng/searxng:latest image: searxng/searxng:latest
container_name: mateclaw-searxng container_name: mateclaw-searxng
restart: unless-stopped restart: unless-stopped
environment: environment:
- SEARXNG_BASE_URL=http://searxng:8080 - SEARXNG_BASE_URL=http://searxng:8080
- SEARXNG_SECRET=${SEARXNG_SECRET:-mateclaw-dev-searxng-secret-change-me}
- UWSGI_WORKERS=2
- UWSGI_THREADS=4
volumes: volumes:
- searxng_data:/etc/searxng - 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: ports:
- "8088:8080" - "8088:8080"
healthcheck: healthcheck:
# Healthz needs json format, so this also doubles as an integration check.
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/healthz"] test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/healthz"]
interval: 30s interval: 30s
timeout: 5s timeout: 5s

View File

@ -81,24 +81,42 @@ public class SearXNGSearchProvider implements SearchProvider {
urlBuilder.append("&time_range=").append(searchQuery.freshness().toLowerCase()); urlBuilder.append("&time_range=").append(searchQuery.freshness().toLowerCase());
} }
String response = HttpUtil.createGet(urlBuilder.toString()) var resp = HttpUtil.createGet(urlBuilder.toString())
.header("Accept", "application/json") .header("Accept", "application/json")
.timeout(15000) .timeout(15000)
.execute() .execute();
.body(); 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); log.debug("SearXNG response for '{}': status={}, contentType={}, length={}",
return parseResponse(response, searchQuery.resolvedCount()); searchQuery.query(), status, contentType, response != null ? response.length() : 0);
return parseResponse(response, status, contentType, searchQuery.resolvedCount(), urlBuilder.toString());
} }
private List<SearchResult> parseResponse(String response, int limit) { private List<SearchResult> parseResponse(String response, int status, String contentType,
int limit, String requestUrl) {
List<SearchResult> results = new ArrayList<>(); List<SearchResult> 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 { try {
JSONObject json = JSONUtil.parseObj(response); JSONObject json = JSONUtil.parseObj(response);
JSONArray items = json.getJSONArray("results"); JSONArray items = json.getJSONArray("results");
if (items == null) return results; if (items == null || items.isEmpty()) return results;
limit = Math.min(items.size(), limit); limit = Math.min(items.size(), limit);
for (int i = 0; i < limit; i++) { for (int i = 0; i < limit; i++) {
@ -114,11 +132,17 @@ public class SearXNGSearchProvider implements SearchProvider {
.build()); .build());
} }
} catch (Exception e) { } catch (Exception e) {
log.warn("SearXNG 结果解析失败: {}", e.getMessage()); log.warn("SearXNG parse failed: {} — preview: {}", e.getMessage(), preview(response));
} }
return results; 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) { private String extractDomain(String url) {
try { try {
return URI.create(url).getHost(); return URI.create(url).getHost();