diff --git a/internal/engines/bing_images.go b/internal/engines/bing_images.go
new file mode 100644
index 0000000..002f947
--- /dev/null
+++ b/internal/engines/bing_images.go
@@ -0,0 +1,123 @@
+// 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/xml"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/metamorphosis-dev/kafka/internal/contracts"
+)
+
+// BingImagesEngine searches Bing Images via their public RSS endpoint.
+type BingImagesEngine struct {
+ client *http.Client
+}
+
+func (e *BingImagesEngine) Name() string { return "bing_images" }
+
+func (e *BingImagesEngine) Search(ctx context.Context, req contracts.SearchRequest) (contracts.SearchResponse, error) {
+ if e == nil || e.client == nil {
+ return contracts.SearchResponse{}, errors.New("bing_images engine not initialized")
+ }
+ q := strings.TrimSpace(req.Query)
+ if q == "" {
+ return contracts.SearchResponse{Query: req.Query}, nil
+ }
+
+ offset := (req.Pageno - 1) * 10
+ endpoint := fmt.Sprintf(
+ "https://www.bing.com/images/search?q=%s&count=10&offset=%d&format=rss",
+ url.QueryEscape(q),
+ offset,
+ )
+
+ 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.StatusOK {
+ io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
+ return contracts.SearchResponse{}, fmt.Errorf("bing_images upstream error: status %d", resp.StatusCode)
+ }
+
+ return parseBingImagesRSS(resp.Body, req.Query)
+}
+
+// parseBingImagesRSS parses Bing's RSS image search results.
+// The description field contains HTML with an tag whose src is the
+// thumbnail and whose enclosing tag links to the source page.
+func parseBingImagesRSS(r io.Reader, query string) (contracts.SearchResponse, error) {
+ type bingImageItem struct {
+ Title string `xml:"title"`
+ Link string `xml:"link"`
+ Descrip string `xml:"description"`
+ }
+
+ type rssFeed struct {
+ XMLName xml.Name `xml:"rss"`
+ Channel struct {
+ Items []bingImageItem `xml:"item"`
+ } `xml:"channel"`
+ }
+
+ var rss rssFeed
+ if err := xml.NewDecoder(r).Decode(&rss); err != nil {
+ return contracts.SearchResponse{}, fmt.Errorf("bing_images RSS parse error: %w", err)
+ }
+
+ results := make([]contracts.MainResult, 0, len(rss.Channel.Items))
+ for _, item := range rss.Channel.Items {
+ if item.Link == "" {
+ continue
+ }
+
+ // Extract thumbnail URL from the description HTML.
+ thumbnail := extractImgSrc(item.Descrip)
+ content := stripHTML(item.Descrip)
+
+ linkPtr := item.Link
+ results = append(results, contracts.MainResult{
+ Template: "images",
+ Title: item.Title,
+ Content: content,
+ URL: &linkPtr,
+ Thumbnail: thumbnail,
+ Engine: "bing_images",
+ Score: 0,
+ Category: "images",
+ Engines: []string{"bing_images"},
+ })
+ }
+
+ return contracts.SearchResponse{
+ Query: query,
+ NumberOfResults: len(results),
+ Results: results,
+ Answers: []map[string]any{},
+ Corrections: []string{},
+ Infoboxes: []map[string]any{},
+ Suggestions: []string{},
+ UnresponsiveEngines: [][2]string{},
+ }, nil
+}
diff --git a/internal/engines/ddg_images.go b/internal/engines/ddg_images.go
new file mode 100644
index 0000000..5764af4
--- /dev/null
+++ b/internal/engines/ddg_images.go
@@ -0,0 +1,207 @@
+// 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
+}
diff --git a/internal/engines/factory.go b/internal/engines/factory.go
index 68f66eb..c3a0d95 100644
--- a/internal/engines/factory.go
+++ b/internal/engines/factory.go
@@ -73,5 +73,9 @@ func NewDefaultPortedEngines(client *http.Client, cfg *config.Config) map[string
apiKey: youtubeAPIKey,
baseURL: "https://www.googleapis.com",
},
+ // Image engines
+ "bing_images": &BingImagesEngine{client: client},
+ "ddg_images": &DuckDuckGoImagesEngine{client: client},
+ "qwant_images": &QwantImagesEngine{client: client},
}
}
diff --git a/internal/engines/html_helpers.go b/internal/engines/html_helpers.go
index 66690c3..c704d7c 100644
--- a/internal/engines/html_helpers.go
+++ b/internal/engines/html_helpers.go
@@ -72,3 +72,14 @@ func htmlUnescape(s string) string {
s = strings.ReplaceAll(s, " ", " ")
return s
}
+
+// extractImgSrc finds the first
in an HTML string and returns
+// the src attribute value.
+func extractImgSrc(html string) string {
+ idx := strings.Index(html, "
= 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
+}
diff --git a/internal/search/request_params.go b/internal/search/request_params.go
index 2e477fb..a7c810d 100644
--- a/internal/search/request_params.go
+++ b/internal/search/request_params.go
@@ -35,6 +35,8 @@ var knownEngineNames = map[string]bool{
"braveapi": true, "brave": true, "qwant": true,
"duckduckgo": true, "github": true, "reddit": true,
"bing": true, "google": true, "youtube": true,
+ // Image engines
+ "bing_images": true, "ddg_images": true, "qwant_images": true,
}
// validateEngines filters engine names against the known registry.
diff --git a/internal/views/static/css/kafka.css b/internal/views/static/css/kafka.css
index 40f0f25..ac740d8 100644
--- a/internal/views/static/css/kafka.css
+++ b/internal/views/static/css/kafka.css
@@ -952,6 +952,100 @@ footer a:hover {
}
}
+/* ============================================================
+ Image Results
+ ============================================================ */
+
+.image-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+ gap: 1rem;
+}
+
+.image-result {
+ display: block;
+ border-radius: var(--radius-sm);
+ overflow: hidden;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ transition: transform 0.15s ease, box-shadow 0.15s ease;
+ text-decoration: none;
+ color: inherit;
+}
+
+.image-result:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px var(--shadow);
+}
+
+.image-result:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+.image-thumb {
+ aspect-ratio: 1;
+ overflow: hidden;
+ background: var(--bg-tertiary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.image-thumb img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+ transition: transform 0.2s ease;
+}
+
+.image-result:hover .image-thumb img {
+ transform: scale(1.05);
+}
+
+.image-thumb.image-error img,
+.image-thumb.image-error {
+ display: none;
+}
+
+.image-placeholder {
+ font-size: 2rem;
+ opacity: 0.3;
+}
+
+.image-meta {
+ padding: 0.5rem;
+ min-height: 2.5rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.15rem;
+}
+
+.image-title {
+ font-size: 0.8rem;
+ font-weight: 500;
+ color: var(--text-primary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.image-source {
+ font-size: 0.7rem;
+ color: var(--text-secondary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+@media (max-width: 480px) {
+ .image-grid {
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+ gap: 0.5rem;
+ }
+}
+
/* ============================================================
Infoboxes
============================================================ */
diff --git a/internal/views/templates/image_item.html b/internal/views/templates/image_item.html
new file mode 100644
index 0000000..ac067ce
--- /dev/null
+++ b/internal/views/templates/image_item.html
@@ -0,0 +1,15 @@
+{{define "image_item"}}
+
+
+ {{else}}
+