Files
goclaw/internal/tools/web_search_serply.go
Serply 407e564b91 feat(tools): add Serply as a web_search provider (#1567)
* feat(tools): add Serply as a web_search provider

Serply is a SERP API returning Google web results on a tenant-supplied
key. It joins the existing chain the same way Exa, Tavily and Brave do:
keyed from config_secrets, skipped silently when no key is stored, and
selectable through the provider parameter for cross-engine corroboration.

Freshness maps onto Google's tbs recency filter, which Serply forwards
upstream. Only the pd/pw/pm/py shortcuts are sent: upstream ignores the
equivalent cdr date range and answers with unfiltered results, so a
range leaves tbs unset rather than naming a filter the results do not
honour.

The settings form gained a fifth sortable provider, so the locked
DuckDuckGo card now derives its position number instead of hardcoding
it, and a stored provider_order is reconciled against the full provider
list rather than against Parallel alone.

* test(tools): use a locally named round trip stub in the serply test

The serply cancellation test borrowed parallelRoundTripFunc from
web_search_parallel_test.go. Define serplyRoundTripFunc alongside the
test that uses it so the file does not depend on another provider's
test helper.
2026-09-26 10:33:07 +07:00

123 lines
3.5 KiB
Go

package tools
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// --- Serply Search Provider ---
//
// Serply returns Google web results as JSON on a tenant-supplied key. Requests
// are GET with the key in an X-Api-Key header; results arrive under "results"
// with title/link/description fields. The API caps a response at 10 results,
// which is already maxSearchCount here, so no extra clamping is needed.
type serplySearchProvider struct {
apiKey string
maxResults int
client *http.Client
endpoint string
}
func newSerplySearchProvider(apiKey string, maxResults int) *serplySearchProvider {
return &serplySearchProvider{
apiKey: apiKey,
maxResults: normalizeProviderMaxResults(maxResults),
client: &http.Client{Timeout: time.Duration(searchTimeoutSeconds) * time.Second},
endpoint: serplySearchEndpoint,
}
}
func (p *serplySearchProvider) Name() string { return searchProviderSerply }
// serplyFreshnessTBS maps the tool's freshness shortcuts onto Google's tbs
// recency filter, which Serply forwards upstream unchanged.
//
// normalizeFreshness also accepts a YYYY-MM-DDtoYYYY-MM-DD range. That form is
// deliberately absent: upstream ignores the equivalent cdr range and answers
// with unfiltered results, so sending it would report a date filter the
// results do not actually honour. A range therefore leaves tbs unset.
var serplyFreshnessTBS = map[string]string{
"pd": "qdr:d",
"pw": "qdr:w",
"pm": "qdr:m",
"py": "qdr:y",
}
func (p *serplySearchProvider) Search(ctx context.Context, params searchParams) ([]searchResult, error) {
limit := clampProviderResultCount(params.Count, p.maxResults)
q := url.Values{}
q.Set("q", params.Query)
q.Set("num", strconv.Itoa(limit))
if params.Country != "" {
q.Set("gl", params.Country)
}
if lang := coalesceSearchText(params.SearchLang, params.UILang); lang != "" {
q.Set("hl", lang)
}
if tbs, ok := serplyFreshnessTBS[normalizeFreshness(params.Freshness)]; ok {
q.Set("tbs", tbs)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.endpoint+"?"+q.Encode(), nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Api-Key", p.apiKey)
// Serply attributes usage by client, so identify the gateway rather than
// sending the browser webSearchUserAgent the scraping providers need.
req.Header.Set("User-Agent", "goclaw")
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("serply API returned %d: %s", resp.StatusCode, truncateStr(string(body), 200))
}
var serplyResp struct {
Results []struct {
Title string `json:"title"`
Link string `json:"link"`
Description string `json:"description"`
} `json:"results"`
}
if err := json.Unmarshal(body, &serplyResp); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
results := make([]searchResult, 0, len(serplyResp.Results))
for _, r := range serplyResp.Results {
if len(results) >= limit {
break
}
link := strings.TrimSpace(r.Link)
if link == "" {
continue
}
results = append(results, searchResult{
Title: coalesceSearchText(r.Title, link, "Untitled"),
URL: link,
Description: truncateStr(strings.TrimSpace(r.Description), 240),
})
}
return results, nil
}