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
This commit is contained in:
parent
a316763aca
commit
2b072e4de3
11 changed files with 687 additions and 4 deletions
123
internal/engines/bing_images.go
Normal file
123
internal/engines/bing_images.go
Normal file
|
|
@ -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 <img> tag whose src is the
|
||||||
|
// thumbnail and whose enclosing <a> 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
|
||||||
|
}
|
||||||
207
internal/engines/ddg_images.go
Normal file
207
internal/engines/ddg_images.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -73,5 +73,9 @@ func NewDefaultPortedEngines(client *http.Client, cfg *config.Config) map[string
|
||||||
apiKey: youtubeAPIKey,
|
apiKey: youtubeAPIKey,
|
||||||
baseURL: "https://www.googleapis.com",
|
baseURL: "https://www.googleapis.com",
|
||||||
},
|
},
|
||||||
|
// Image engines
|
||||||
|
"bing_images": &BingImagesEngine{client: client},
|
||||||
|
"ddg_images": &DuckDuckGoImagesEngine{client: client},
|
||||||
|
"qwant_images": &QwantImagesEngine{client: client},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,3 +72,14 @@ func htmlUnescape(s string) string {
|
||||||
s = strings.ReplaceAll(s, " ", " ")
|
s = strings.ReplaceAll(s, " ", " ")
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractImgSrc finds the first <img src="..."> in an HTML string and returns
|
||||||
|
// the src attribute value.
|
||||||
|
func extractImgSrc(html string) string {
|
||||||
|
idx := strings.Index(html, "<img")
|
||||||
|
if idx == -1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
remaining := html[idx:]
|
||||||
|
return extractAttr(remaining, "src")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,13 @@ import (
|
||||||
"github.com/metamorphosis-dev/kafka/internal/contracts"
|
"github.com/metamorphosis-dev/kafka/internal/contracts"
|
||||||
)
|
)
|
||||||
|
|
||||||
var defaultPortedEngines = []string{"wikipedia", "arxiv", "crossref", "braveapi", "brave", "qwant", "duckduckgo", "github", "reddit", "bing", "google", "youtube"}
|
var defaultPortedEngines = []string{
|
||||||
|
"wikipedia", "arxiv", "crossref", "braveapi",
|
||||||
|
"brave", "qwant", "duckduckgo", "github", "reddit",
|
||||||
|
"bing", "google", "youtube",
|
||||||
|
// Image engines
|
||||||
|
"bing_images", "ddg_images", "qwant_images",
|
||||||
|
}
|
||||||
|
|
||||||
type Planner struct {
|
type Planner struct {
|
||||||
PortedSet map[string]bool
|
PortedSet map[string]bool
|
||||||
|
|
@ -114,6 +120,10 @@ func inferFromCategories(categories []string) []string {
|
||||||
set["reddit"] = true
|
set["reddit"] = true
|
||||||
case "videos":
|
case "videos":
|
||||||
set["youtube"] = true
|
set["youtube"] = true
|
||||||
|
case "images":
|
||||||
|
set["bing_images"] = true
|
||||||
|
set["ddg_images"] = true
|
||||||
|
set["qwant_images"] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,7 +132,11 @@ func inferFromCategories(categories []string) []string {
|
||||||
out = append(out, e)
|
out = append(out, e)
|
||||||
}
|
}
|
||||||
// stable order
|
// stable order
|
||||||
order := map[string]int{"wikipedia": 0, "braveapi": 1, "brave": 2, "qwant": 3, "duckduckgo": 4, "bing": 5, "google": 6, "arxiv": 7, "crossref": 8, "github": 9, "reddit": 10, "youtube": 11}
|
order := map[string]int{
|
||||||
|
"wikipedia": 0, "braveapi": 1, "brave": 2, "qwant": 3, "duckduckgo": 4, "bing": 5, "google": 6,
|
||||||
|
"arxiv": 7, "crossref": 8, "github": 9, "reddit": 10, "youtube": 11,
|
||||||
|
"bing_images": 12, "ddg_images": 13, "qwant_images": 14,
|
||||||
|
}
|
||||||
sortByOrder(out, order)
|
sortByOrder(out, order)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
|
||||||
199
internal/engines/qwant_images.go
Normal file
199
internal/engines/qwant_images.go
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
@ -35,6 +35,8 @@ var knownEngineNames = map[string]bool{
|
||||||
"braveapi": true, "brave": true, "qwant": true,
|
"braveapi": true, "brave": true, "qwant": true,
|
||||||
"duckduckgo": true, "github": true, "reddit": true,
|
"duckduckgo": true, "github": true, "reddit": true,
|
||||||
"bing": true, "google": true, "youtube": 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.
|
// validateEngines filters engine names against the known registry.
|
||||||
|
|
|
||||||
|
|
@ -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
|
Infoboxes
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
|
||||||
15
internal/views/templates/image_item.html
Normal file
15
internal/views/templates/image_item.html
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{{define "image_item"}}
|
||||||
|
<a class="image-result" href="{{.URL}}" target="_blank" rel="noopener noreferrer">
|
||||||
|
<div class="image-thumb">
|
||||||
|
{{if .Thumbnail}}
|
||||||
|
<img src="{{.Thumbnail}}" alt="{{.Title}}" loading="lazy" onerror="this.parentElement.classList.add('image-error')">
|
||||||
|
{{else}}
|
||||||
|
<div class="image-placeholder">🖼️</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="image-meta">
|
||||||
|
<span class="image-title">{{.Title}}</span>
|
||||||
|
{{if .Content}}<span class="image-source">{{.Content}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
|
@ -19,13 +19,25 @@
|
||||||
|
|
||||||
<div id="urls" role="main">
|
<div id="urls" role="main">
|
||||||
{{if .Results}}
|
{{if .Results}}
|
||||||
|
{{if .IsImageSearch}}
|
||||||
|
<div class="image-grid">
|
||||||
|
{{range .Results}}
|
||||||
|
{{if eq .Template "images"}}
|
||||||
|
{{template "image_item" .}}
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
{{range .Results}}
|
{{range .Results}}
|
||||||
{{if eq .Template "videos"}}
|
{{if eq .Template "videos"}}
|
||||||
{{template "video_item" .}}
|
{{template "video_item" .}}
|
||||||
|
{{else if eq .Template "images"}}
|
||||||
|
{{template "image_item" .}}
|
||||||
{{else}}
|
{{else}}
|
||||||
{{template "result_item" .}}
|
{{template "result_item" .}}
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{end}}
|
||||||
{{else if not .Answers}}
|
{{else if not .Answers}}
|
||||||
<div class="no-results">
|
<div class="no-results">
|
||||||
<div class="no-results-icon">🔍</div>
|
<div class="no-results-icon">🔍</div>
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ type PageData struct {
|
||||||
UnresponsiveEngines [][2]string
|
UnresponsiveEngines [][2]string
|
||||||
PageNumbers []PageNumber
|
PageNumbers []PageNumber
|
||||||
ShowHeader bool
|
ShowHeader bool
|
||||||
|
IsImageSearch bool
|
||||||
// New fields for three-column layout
|
// New fields for three-column layout
|
||||||
Categories []string
|
Categories []string
|
||||||
CategoryIcons map[string]string
|
CategoryIcons map[string]string
|
||||||
|
|
@ -106,13 +107,13 @@ func init() {
|
||||||
}
|
}
|
||||||
|
|
||||||
tmplFull = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS,
|
tmplFull = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS,
|
||||||
"base.html", "results.html", "results_inner.html", "result_item.html", "video_item.html",
|
"base.html", "results.html", "results_inner.html", "result_item.html", "video_item.html", "image_item.html",
|
||||||
))
|
))
|
||||||
tmplIndex = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS,
|
tmplIndex = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS,
|
||||||
"base.html", "index.html",
|
"base.html", "index.html",
|
||||||
))
|
))
|
||||||
tmplFragment = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS,
|
tmplFragment = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS,
|
||||||
"results.html", "results_inner.html", "result_item.html", "video_item.html",
|
"results.html", "results_inner.html", "result_item.html", "video_item.html", "image_item.html",
|
||||||
))
|
))
|
||||||
tmplPreferences = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS,
|
tmplPreferences = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS,
|
||||||
"base.html", "preferences.html",
|
"base.html", "preferences.html",
|
||||||
|
|
@ -168,6 +169,7 @@ func FromResponse(resp contracts.SearchResponse, query string, pageno int, activ
|
||||||
"weather": "🌤️",
|
"weather": "🌤️",
|
||||||
},
|
},
|
||||||
ActiveCategory: activeCategory,
|
ActiveCategory: activeCategory,
|
||||||
|
IsImageSearch: activeCategory == "images",
|
||||||
|
|
||||||
// Time filters
|
// Time filters
|
||||||
TimeFilters: []FilterOption{
|
TimeFilters: []FilterOption{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue