Update LICENSE file and add AGPL header to all source files. AGPLv3 ensures that if someone runs Kafka as a network service and modifies it, they must release their source code under the same license.
140 lines
3.7 KiB
Go
140 lines
3.7 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.
|
|
//
|
|
// 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 <https://www.gnu.org/licenses/>.
|
|
|
|
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
|
|
}
|