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
199 lines
5.5 KiB
Go
199 lines
5.5 KiB
Go
// 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
|
||
}
|