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
207 lines
5.6 KiB
Go
207 lines
5.6 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"
|
||
"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
|
||
}
|