// kafka — a privacy-respecting metasearch engine // Copyright (C) 2026-present metamorphosis-dev // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. package engines import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "github.com/metamorphosis-dev/kafka/internal/contracts" ) // QwantImagesEngine searches Qwant Images via the v3 search API. type QwantImagesEngine struct { client *http.Client } func (e *QwantImagesEngine) Name() string { return "qwant_images" } func (e *QwantImagesEngine) Search(ctx context.Context, req contracts.SearchRequest) (contracts.SearchResponse, error) { if e == nil || e.client == nil { return contracts.SearchResponse{}, errors.New("qwant_images engine not initialized") } q := strings.TrimSpace(req.Query) if q == "" { return contracts.SearchResponse{Query: req.Query}, nil } args := url.Values{} args.Set("q", req.Query) args.Set("count", "20") args.Set("locale", qwantLocale(req.Language)) args.Set("safesearch", fmt.Sprintf("%d", req.Safesearch)) args.Set("offset", fmt.Sprintf("%d", (req.Pageno-1)*20)) endpoint := "https://api.qwant.com/v3/search/images?" + args.Encode() httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return contracts.SearchResponse{}, err } httpReq.Header.Set("User-Agent", "kafka/0.1 (compatible; +https://git.ashisgreat.xyz/penal-colony/kafka)") resp, err := e.client.Do(httpReq) if err != nil { return contracts.SearchResponse{}, err } defer resp.Body.Close() if resp.StatusCode == http.StatusForbidden { return contracts.SearchResponse{ Query: req.Query, UnresponsiveEngines: [][2]string{{"qwant_images", "captcha_or_js_block"}}, Results: []contracts.MainResult{}, Answers: []map[string]any{}, Corrections: []string{}, Infoboxes: []map[string]any{}, Suggestions: []string{}, }, nil } if resp.StatusCode < 200 || resp.StatusCode >= 300 { io.Copy(io.Discard, io.LimitReader(resp.Body, 16*1024)) return contracts.SearchResponse{}, fmt.Errorf("qwant_images upstream error: status %d", resp.StatusCode) } body, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) if err != nil { return contracts.SearchResponse{}, err } return parseQwantImages(body, req.Query) } func parseQwantImages(body []byte, query string) (contracts.SearchResponse, error) { var top map[string]any if err := json.Unmarshal(body, &top); err != nil { return contracts.SearchResponse{}, fmt.Errorf("qwant_images JSON parse error: %w", err) } status, _ := top["status"].(string) if status != "success" { return contracts.SearchResponse{ Query: query, UnresponsiveEngines: [][2]string{{"qwant_images", "api_error"}}, Results: []contracts.MainResult{}, Answers: []map[string]any{}, Corrections: []string{}, Infoboxes: []map[string]any{}, Suggestions: []string{}, }, nil } data, _ := top["data"].(map[string]any) result, _ := data["result"].(map[string]any) items, _ := result["items"].(map[string]any) mainline := items["mainline"] rows := toSlice(mainline) if len(rows) == 0 { return contracts.SearchResponse{ Query: query, NumberOfResults: 0, Results: []contracts.MainResult{}, Answers: []map[string]any{}, Corrections: []string{}, Infoboxes: []map[string]any{}, Suggestions: []string{}, UnresponsiveEngines: [][2]string{}, }, nil } out := make([]contracts.MainResult, 0) for _, row := range rows { rowMap, ok := row.(map[string]any) if !ok { continue } rowType, _ := rowMap["type"].(string) if rowType != "images" { continue } rowItems := toSlice(rowMap["items"]) for _, it := range rowItems { itemMap, ok := it.(map[string]any) if !ok { continue } title := toString(itemMap["title"]) resURL := toString(itemMap["url"]) thumb := toString(itemMap["thumbnail"]) fullImg := toString(itemMap["media"]) source := toString(itemMap["source"]) if resURL == "" && fullImg == "" { continue } // Use the source page URL for the link, full image for thumbnail display. linkPtr := resURL if linkPtr == "" { linkPtr = fullImg } displayThumb := fullImg if displayThumb == "" { displayThumb = thumb } content := source if width, ok := itemMap["width"]; ok { w := toString(width) if h, ok2 := itemMap["height"]; ok2 { h2 := toString(h) if w != "" && h2 != "" { content = w + " × " + h2 if source != "" { content += " — " + source } } } } out = append(out, contracts.MainResult{ Template: "images", Title: title, Content: content, URL: &linkPtr, Thumbnail: displayThumb, Engine: "qwant_images", Score: 0, Category: "images", Engines: []string{"qwant_images"}, }) } } return contracts.SearchResponse{ Query: query, NumberOfResults: len(out), Results: out, Answers: []map[string]any{}, Corrections: []string{}, Infoboxes: []map[string]any{}, Suggestions: []string{}, UnresponsiveEngines: [][2]string{}, }, nil }