// 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. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package autocomplete import ( "context" "encoding/json" "errors" "io" "net/http" "net/url" "strings" "time" ) // Service fetches search suggestions from an upstream metasearch instance // or falls back to Wikipedia's OpenSearch API. type Service struct { upstreamURL string http *http.Client } func NewService(upstreamURL string, timeout time.Duration) *Service { if timeout <= 0 { timeout = 5 * time.Second } return &Service{ upstreamURL: strings.TrimRight(upstreamURL, "/"), http: &http.Client{Timeout: timeout}, } } // Suggestions returns search suggestions for the given query. func (s *Service) Suggestions(ctx context.Context, query string) ([]string, error) { if strings.TrimSpace(query) == "" { return nil, nil } if s.upstreamURL != "" { return s.upstreamSuggestions(ctx, query) } return s.wikipediaSuggestions(ctx, query) } // upstreamSuggestions proxies to an upstream /autocompleter endpoint. func (s *Service) upstreamSuggestions(ctx context.Context, query string) ([]string, error) { u := s.upstreamURL + "/autocompleter?" + url.Values{"q": {query}}.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return nil, err } req.Header.Set("Accept", "application/json") resp, err := s.http.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, errors.New("upstream autocompleter failed") } body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) if err != nil { return nil, err } // The /autocompleter endpoint returns a plain JSON array of strings. var out []string if err := json.Unmarshal(body, &out); err != nil { return nil, err } return out, nil } // wikipediaSuggestions fetches suggestions from Wikipedia's OpenSearch API. func (s *Service) wikipediaSuggestions(ctx context.Context, query string) ([]string, error) { u := "https://en.wikipedia.org/w/api.php?" + url.Values{ "action": {"opensearch"}, "format": {"json"}, "formatversion": {"2"}, "search": {query}, "namespace": {"0"}, "limit": {"10"}, }.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return nil, err } req.Header.Set( "User-Agent", "gosearch-go/0.1 (compatible; +https://github.com/metamorphosis-dev/kafka)", ) resp, err := s.http.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, errors.New("wikipedia opensearch failed") } body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) if err != nil { return nil, err } // Wikipedia OpenSearch returns: [query, [suggestions], ...] var data []json.RawMessage if err := json.Unmarshal(body, &data); err != nil { return nil, err } if len(data) < 2 { return nil, nil } var suggestions []string if err := json.Unmarshal(data[1], &suggestions); err != nil { return nil, err } return suggestions, nil }