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