samsa/internal/engines/qwant_images.go
Franz Kafka 2b072e4de3
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 6s
Mirror to GitHub / mirror (push) Failing after 3s
Tests / test (push) Successful in 25s
feat: add image search with Bing, DuckDuckGo, and Qwant engines
Three new image search engines:
- bing_images: Bing Images via RSS endpoint
- ddg_images: DuckDuckGo Images via VQD API
- qwant_images: Qwant Images via v3 search API

Frontend:
- Image grid layout with responsive columns
- image_item template with thumbnail, title, and source metadata
- Hover animations and lazy loading
- Grid activates automatically when category=images

Backend:
- category=images routes to image engines via planner
- Image engines registered in factory and engine allowlist
- extractImgSrc helper for parsing thumbnail URLs from HTML
- IsImageSearch flag on PageData for template layout switching
2026-03-22 16:49:24 +00:00

199 lines
5.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
}