samsa/internal/engines/ddg_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

207 lines
5.6 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"
"strconv"
"strings"
"github.com/metamorphosis-dev/kafka/internal/contracts"
)
// DuckDuckGoImagesEngine searches DuckDuckGo Images via their vql API.
type DuckDuckGoImagesEngine struct {
client *http.Client
}
func (e *DuckDuckGoImagesEngine) Name() string { return "ddg_images" }
func (e *DuckDuckGoImagesEngine) Search(ctx context.Context, req contracts.SearchRequest) (contracts.SearchResponse, error) {
if e == nil || e.client == nil {
return contracts.SearchResponse{}, errors.New("ddg_images engine not initialized")
}
q := strings.TrimSpace(req.Query)
if q == "" {
return contracts.SearchResponse{Query: req.Query}, nil
}
// Step 1: Get a VQD token from the initial search page.
vqd, err := e.getVQD(ctx, q)
if err != nil {
return contracts.SearchResponse{
Query: req.Query,
UnresponsiveEngines: [][2]string{{"ddg_images", "vqd_fetch_failed"}},
Results: []contracts.MainResult{},
Answers: []map[string]any{},
Corrections: []string{},
Infoboxes: []map[string]any{},
Suggestions: []string{},
}, nil
}
// Step 2: Fetch image results using the VQD token.
endpoint := fmt.Sprintf(
"https://duckduckgo.com/i.js?q=%s&kl=wt-wt&l=wt-wt&p=1&s=%d&vqd=%s",
url.QueryEscape(q),
(req.Pageno-1)*50,
url.QueryEscape(vqd),
)
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)")
httpReq.Header.Set("Referer", "https://duckduckgo.com/")
resp, err := e.client.Do(httpReq)
if err != nil {
return contracts.SearchResponse{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
io.Copy(io.Discard, io.LimitReader(resp.Body, 16*1024))
return contracts.SearchResponse{}, fmt.Errorf("ddg_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 parseDDGImages(body, req.Query)
}
// getVQD fetches a VQD token from DuckDuckGo's search page.
func (e *DuckDuckGoImagesEngine) getVQD(ctx context.Context, query string) (string, error) {
endpoint := "https://duckduckgo.com/?q=" + url.QueryEscape(query)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return "", 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 "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024))
if err != nil {
return "", err
}
// Extract VQD from the HTML: vqd='...'
vqd := extractVQD(string(body))
if vqd == "" {
return "", fmt.Errorf("vqd token not found in response")
}
return vqd, nil
}
// extractVQD extracts the VQD token from DuckDuckGo's HTML response.
func extractVQD(html string) string {
// Look for: vqd='...' or vqd="..."
for _, prefix := range []string{"vqd='", `vqd="`} {
idx := strings.Index(html, prefix)
if idx == -1 {
continue
}
start := idx + len(prefix)
end := start
for end < len(html) && html[end] != '\'' && html[end] != '"' {
end++
}
if end > start {
return html[start:end]
}
}
return ""
}
// ddgImageResult represents a single image result from DDG's JSON API.
type ddgImageResult struct {
Title string `json:"title"`
URL string `json:"url"`
Thumbnail string `json:"thumbnail"`
Image string `json:"image"`
Width int `json:"width"`
Height int `json:"height"`
Source string `json:"source"`
}
func parseDDGImages(body []byte, query string) (contracts.SearchResponse, error) {
var results struct {
Results []ddgImageResult `json:"results"`
}
if err := json.Unmarshal(body, &results); err != nil {
return contracts.SearchResponse{}, fmt.Errorf("ddg_images JSON parse error: %w", err)
}
out := make([]contracts.MainResult, 0, len(results.Results))
for _, img := range results.Results {
if img.URL == "" {
continue
}
// Prefer the full image URL as thumbnail, fall back to the thumbnail field.
thumb := img.Image
if thumb == "" {
thumb = img.Thumbnail
}
// Build a simple content string showing dimensions.
content := ""
if img.Width > 0 && img.Height > 0 {
content = strconv.Itoa(img.Width) + " × " + strconv.Itoa(img.Height)
}
if img.Source != "" {
if content != "" {
content += " — " + img.Source
} else {
content = img.Source
}
}
urlPtr := img.URL
out = append(out, contracts.MainResult{
Template: "images",
Title: img.Title,
Content: content,
URL: &urlPtr,
Thumbnail: thumb,
Engine: "ddg_images",
Score: 0,
Category: "images",
Engines: []string{"ddg_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
}