fix default search limits

This commit is contained in:
Rustem Kamalov
2026-06-02 21:44:58 +03:00
parent 1573642de8
commit 2b4a80fcb2
18 changed files with 231 additions and 41 deletions

View File

@@ -124,7 +124,7 @@ Common parameters:
| `date` | Date range | `20250101..20251231` | | `date` | Date range | `20250101..20251231` |
| `file` | File extension | `pdf`, `doc`, `xls` | | `file` | File extension | `pdf`, `doc`, `xls` |
| `site` | Site-specific search | `github.com` | | `site` | Site-specific search | `github.com` |
| `limit` | Number of organic results, max 100. Ads may be returned in addition. | `10`, `25`, `50` | | `limit` | Number of organic results, max 100. When omitted or `<=10`, only the first SERP page is parsed. | `25`, `50` |
| `start` | Pagination offset | `0`, `10`, `20` | | `start` | Pagination offset | `0`, `10`, `20` |
| `format` | Output format | `json`, `markdown`, `text`, `ndjson` | | `format` | Output format | `json`, `markdown`, `text`, `ndjson` |
@@ -133,7 +133,7 @@ Engine-specific parameters:
| Parameter | Supported engines | Notes | | Parameter | Supported engines | Notes |
| ---------- | ----------------- | ---------------------------------------------------------------------- | | ---------- | ----------------- | ---------------------------------------------------------------------- |
| `filter` | `google` | Duplicate filter: `true` hides similar results, `false` includes them. | | `filter` | `google` | Duplicate filter: `true` hides similar results, `false` includes them. |
| `features` | browser `Search` | Populate `serp_features[]` from the live page . | | `features` | browser `Search` | Populate `serp_features[]` from the live page. Defaults to `true`. |
## Search Response Example ## Search Response Example

View File

@@ -35,7 +35,7 @@ func TestBuildURL(t *testing.T) {
if got := params.Get("gpc"); got != "stf=1388534400,1672531200|stftype=2" { if got := params.Get("gpc"); got != "stf=1388534400,1672531200|stftype=2" {
t.Fatalf("unexpected gpc value: %q", got) t.Fatalf("unexpected gpc value: %q", got)
} }
if got := params.Get("rn"); got != "10" { if got := params.Get("rn"); got != "" {
t.Fatalf("unexpected rn value: %q", got) t.Fatalf("unexpected rn value: %q", got)
} }
if got := params.Get("pn"); got != "" { if got := params.Get("pn"); got != "" {
@@ -140,6 +140,23 @@ func TestBuildImageURL(t *testing.T) {
} }
}, },
}, },
{
name: "small limit omits result count param",
query: core.Query{
Text: "golang",
Limit: 10,
},
pageNum: 2,
check: func(t *testing.T, params url.Values, _ string) {
t.Helper()
if got := params.Get("rn"); got != "" {
t.Fatalf("rn should be omitted when Limit<=10, got %q", got)
}
if got := params.Get("pn"); got != "60" {
t.Fatalf("unexpected pn value: %q", got)
}
},
},
{ {
name: "empty fields return error", name: "empty fields return error",
query: core.Query{}, query: core.Query{},

View File

@@ -278,13 +278,13 @@ func (baid *Baidu) SearchImage(ctx context.Context, query core.Query) ([]core.Se
return false, nil return false, nil
} }
for len(searchResults) < query.Limit { for core.ShouldFetchResultPage(len(searchResults), query.Limit, searchPage) {
done, err := fetchPage() done, err := fetchPage()
if err != nil { if err != nil {
return nil, err return nil, err
} }
searchPage++ searchPage++
if done { if done || !core.ShouldFetchResultPage(len(searchResults), query.Limit, searchPage) {
break break
} }
} }

View File

@@ -65,7 +65,7 @@ func BuildURL(q core.Query) (string, error) {
logrus.Warn("File search not supported") logrus.Warn("File search not supported")
} }
if q.Limit != 0 { if q.Limit > 10 {
params.Add("rn", strconv.Itoa(q.Limit)) params.Add("rn", strconv.Itoa(q.Limit))
} }
if q.Start < 0 { if q.Start < 0 {
@@ -104,8 +104,10 @@ func BuildImageURL(q core.Query, pageNum int) (string, error) {
return "", errors.New("Empty query built") return "", errors.New("Empty query built")
} }
if q.Limit != 0 { if q.Limit > 10 {
params.Add("rn", "30") // Results per page params.Add("rn", "30") // Results per page
}
if pageNum > 0 {
params.Add("pn", strconv.Itoa(pageNum*30)) // Offset params.Add("pn", strconv.Itoa(pageNum*30)) // Offset
} }

View File

@@ -82,6 +82,9 @@ func TestBuildURL(t *testing.T) {
if got := params.Get("filters"); got != `ex1:"ez5_20454_20570"` { if got := params.Get("filters"); got != `ex1:"ez5_20454_20570"` {
t.Fatalf("unexpected filters value: %q", got) t.Fatalf("unexpected filters value: %q", got)
} }
if got := params.Get("count"); got != "" {
t.Fatalf("count should be omitted when Limit<=10, got %q", got)
}
// LangCode unset → no locale params; let Bing pick defaults // LangCode unset → no locale params; let Bing pick defaults
// from the request rather than biasing toward en-US. // from the request rather than biasing toward en-US.
for _, key := range []string{"mkt", "setlang", "cc"} { for _, key := range []string{"mkt", "setlang", "cc"} {

View File

@@ -89,7 +89,7 @@ func BuildURL(q core.Query) (string, error) {
if q.Start > 0 { if q.Start > 0 {
// Bing uses 1-based first-result index for pagination. // Bing uses 1-based first-result index for pagination.
params.Add("first", strconv.Itoa(q.Start+1)) params.Add("first", strconv.Itoa(q.Start+1))
} else if q.Limit > 0 { } else if q.Limit > 10 {
params.Add("count", strconv.Itoa(q.Limit)) params.Add("count", strconv.Itoa(q.Limit))
} }

View File

@@ -16,7 +16,7 @@ import (
) )
const ( const (
version = "0.7.15" version = "0.7.16"
defaultConfigFilename = "config" defaultConfigFilename = "config"
envPrefix = "OPENSERP" envPrefix = "OPENSERP"
) )

View File

@@ -171,6 +171,19 @@ func OrganicLimitReached(results []SearchResult, limit int) bool {
return limit > 0 && CountOrganicResults(results) >= limit return limit > 0 && CountOrganicResults(results) >= limit
} }
// ShouldFetchResultPage reports whether a paginated engine should fetch another
// SERP page. Small/default limits should use the first SERP page as-is instead
// of chasing a target count across multiple page loads.
func ShouldFetchResultPage(collected, limit, pagesFetched int) bool {
if pagesFetched <= 0 {
return true
}
if limit > 0 && collected >= limit {
return false
}
return limit > defaultQueryLimit
}
// LimitOrganicResults keeps all ads and at most limit non-ad results. // LimitOrganicResults keeps all ads and at most limit non-ad results.
func LimitOrganicResults(results []SearchResult, limit int) []SearchResult { func LimitOrganicResults(results []SearchResult, limit int) []SearchResult {
if limit <= 0 { if limit <= 0 {
@@ -356,7 +369,7 @@ func (searchQuery *Query) InitFromContext(reqCtx *fiber.Ctx) error {
return errInvalidParam(fmt.Sprintf("filter: %v", err)) return errInvalidParam(fmt.Sprintf("filter: %v", err))
} }
searchQuery.Features, err = strconv.ParseBool(reqCtx.Query("features", "0")) searchQuery.Features, err = strconv.ParseBool(reqCtx.Query("features", "1"))
if err != nil { if err != nil {
return errInvalidParam(fmt.Sprintf("features: %v", err)) return errInvalidParam(fmt.Sprintf("features: %v", err))
} }

58
core/common_test.go Normal file
View File

@@ -0,0 +1,58 @@
package core
import "testing"
func TestShouldFetchResultPage(t *testing.T) {
tests := []struct {
name string
collected int
limit int
pagesFetched int
want bool
}{
{
name: "always fetch first page",
limit: 10,
pagesFetched: 0,
want: true,
},
{
name: "unset limit stops after first page",
collected: 8,
limit: 0,
pagesFetched: 1,
want: false,
},
{
name: "default limit stops after first page even if short",
collected: 8,
limit: 10,
pagesFetched: 1,
want: false,
},
{
name: "larger limit can fetch another short page",
collected: 8,
limit: 11,
pagesFetched: 1,
want: true,
},
{
name: "larger limit stops when satisfied",
collected: 11,
limit: 11,
pagesFetched: 1,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ShouldFetchResultPage(tt.collected, tt.limit, tt.pagesFetched)
if got != tt.want {
t.Fatalf("ShouldFetchResultPage(%d, %d, %d) = %t, want %t",
tt.collected, tt.limit, tt.pagesFetched, got, tt.want)
}
})
}
}

View File

@@ -341,6 +341,40 @@ func TestInvalidQueryParametersReturnJSONError(t *testing.T) {
} }
} }
func TestFeaturesDefaultEnabledAndCanBeDisabled(t *testing.T) {
var got []bool
engine := &engineMock{
name: "google",
initialized: true,
searchFn: func(_ context.Context, q Query) ([]SearchResult, error) {
got = append(got, q.Features)
return []SearchResult{{Rank: 1, URL: "https://example.com/google", Title: "google"}}, nil
},
}
opts := DefaultServerOptions()
opts.Resilience.Retry.MaxRetries = 0
srv := NewServerWithOptions("127.0.0.1", 7240, opts, engine)
resp := request(t, srv, "/google/search?text=golang")
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected default features request to succeed, got %d", resp.StatusCode)
}
resp = request(t, srv, "/google/search?text=golang&features=false")
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected features=false request to succeed, got %d", resp.StatusCode)
}
if len(got) != 2 {
t.Fatalf("expected 2 engine calls, got %d", len(got))
}
if !got[0] {
t.Fatal("expected features to default to true")
}
if got[1] {
t.Fatal("expected features=false to disable feature extraction")
}
}
func TestMegaEnginesEndpointResponseFormat(t *testing.T) { func TestMegaEnginesEndpointResponseFormat(t *testing.T) {
google := &engineMock{name: "google", initialized: true} google := &engineMock{name: "google", initialized: true}
yandex := &engineMock{name: "yandex", initialized: false} yandex := &engineMock{name: "yandex", initialized: false}

View File

@@ -637,12 +637,12 @@ components:
name: limit name: limit
in: query in: query
required: false required: false
description: Maximum organic results to return (1100). Ads may be returned in addition. description: Maximum organic results to return (1-100). Ads may be returned in addition. Omitted or small limits (<=10) parse only the first SERP page; larger limits may paginate when an engine supports it.
schema: schema:
type: integer type: integer
minimum: 1 minimum: 1
maximum: 100 maximum: 100
default: 25 default: 10
example: 10 example: 10
StartQuery: StartQuery:
name: start name: start
@@ -672,7 +672,7 @@ components:
supported by the engine. supported by the engine.
schema: schema:
type: boolean type: boolean
default: false default: true
EnginesQuery: EnginesQuery:
name: engines name: engines
in: query in: query

View File

@@ -219,13 +219,13 @@ func (ddg *DuckDuckGo) Search(ctx context.Context, query core.Query) (results []
return false, nil return false, nil
} }
for query.Limit <= 0 || core.CountOrganicResults(allResults) < query.Limit { for core.ShouldFetchResultPage(core.CountOrganicResults(allResults), query.Limit, searchPage) {
done, err := fetchPage() done, err := fetchPage()
if err != nil { if err != nil {
return nil, err return nil, err
} }
searchPage++ searchPage++
if done || (query.Limit > 0 && core.CountOrganicResults(allResults) >= query.Limit) { if done || !core.ShouldFetchResultPage(core.CountOrganicResults(allResults), query.Limit, searchPage) {
break break
} }
if err := core.SleepContext(ctx, ddg.pageSleep); err != nil { if err := core.SleepContext(ctx, ddg.pageSleep); err != nil {

View File

@@ -256,3 +256,68 @@ func TestDuckDuckGoLanguageMapping(t *testing.T) {
}) })
} }
} }
func TestShouldFetchDuckDuckGoPage(t *testing.T) {
results := func(organic, ads int) []core.SearchResult {
out := make([]core.SearchResult, 0, organic+ads)
for i := 0; i < ads; i++ {
out = append(out, core.SearchResult{URL: "https://ad.example/" + string(rune('a'+i)), Ad: true})
}
for i := 0; i < organic; i++ {
out = append(out, core.SearchResult{URL: "https://example.com/" + string(rune('a'+i))})
}
return out
}
tests := []struct {
name string
results []core.SearchResult
limit int
pagesFetched int
want bool
}{
{
name: "first page is always fetched",
limit: 10,
pagesFetched: 0,
want: true,
},
{
name: "default limit does not chase a short first page",
results: results(8, 2),
limit: 10,
pagesFetched: 1,
want: false,
},
{
name: "explicit larger limit can paginate",
results: results(8, 0),
limit: 11,
pagesFetched: 1,
want: true,
},
{
name: "satisfied larger limit stops",
results: results(11, 0),
limit: 11,
pagesFetched: 1,
want: false,
},
{
name: "unset internal query stops after first page",
results: results(8, 0),
limit: 0,
pagesFetched: 1,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := core.ShouldFetchResultPage(core.CountOrganicResults(tt.results), tt.limit, tt.pagesFetched)
if got != tt.want {
t.Fatalf("ShouldFetchResultPage() = %t, want %t", got, tt.want)
}
})
}
}

View File

@@ -203,16 +203,13 @@ func (e *Ecosia) Search(ctx context.Context, query core.Query) (results []core.S
return false, nil return false, nil
} }
for query.Limit <= 0 || core.CountOrganicResults(all) < query.Limit { for core.ShouldFetchResultPage(core.CountOrganicResults(all), query.Limit, pageNum-firstPage) {
done, err := fetchPage() done, err := fetchPage()
if err != nil { if err != nil {
return nil, err return nil, err
} }
pageNum++ pageNum++
if done { if done || !core.ShouldFetchResultPage(core.CountOrganicResults(all), query.Limit, pageNum-firstPage) {
break
}
if query.Limit > 0 && core.CountOrganicResults(all) >= query.Limit {
break break
} }
if err := core.SleepContext(ctx, e.pageSleep); err != nil { if err := core.SleepContext(ctx, e.pageSleep); err != nil {
@@ -347,16 +344,13 @@ func (e *Ecosia) SearchImage(ctx context.Context, query core.Query) (results []c
return false, nil return false, nil
} }
for query.Limit <= 0 || len(out) < query.Limit { for core.ShouldFetchResultPage(len(out), query.Limit, pageNum) {
done, err := fetchPage() done, err := fetchPage()
if err != nil { if err != nil {
return nil, err return nil, err
} }
pageNum++ pageNum++
if done { if done || !core.ShouldFetchResultPage(len(out), query.Limit, pageNum) {
break
}
if query.Limit > 0 && len(out) >= query.Limit {
break break
} }
if err := core.SleepContext(ctx, e.pageSleep); err != nil { if err := core.SleepContext(ctx, e.pageSleep); err != nil {

View File

@@ -493,7 +493,7 @@ func (gogl *Google) SearchImage(ctx context.Context, query core.Query) ([]core.S
// cells can hang on right-click. Cap iterations as a last-resort guard. // cells can hang on right-click. Cap iterations as a last-resort guard.
const maxImagePasses = 20 const maxImagePasses = 20
stagnant := 0 stagnant := 0
for pass := 0; pass < maxImagePasses && len(searchResultsMap) < query.Limit; pass++ { for pass := 0; pass < maxImagePasses && core.ShouldFetchResultPage(len(searchResultsMap), query.Limit, pass); pass++ {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return *core.ConvertSearchResultsMap(searchResultsMap), err return *core.ConvertSearchResultsMap(searchResultsMap), err
} }
@@ -526,7 +526,7 @@ func (gogl *Google) SearchImage(ctx context.Context, query core.Query) ([]core.S
if err := r.Remove(); err != nil { if err := r.Remove(); err != nil {
gogl.logger.Debug("Failed to remove parsed image element: %s", err) gogl.logger.Debug("Failed to remove parsed image element: %s", err)
} }
if len(searchResultsMap) >= query.Limit { if query.Limit > 0 && len(searchResultsMap) >= query.Limit {
break break
} }
} }

View File

@@ -72,12 +72,16 @@ func TestBuildSearchURL(t *testing.T) {
Text: "megadeth tickets", Text: "megadeth tickets",
Filter: true, Filter: true,
LangCode: "DE", LangCode: "DE",
Limit: 10,
}, },
check: func(t *testing.T, params url.Values, host string) { check: func(t *testing.T, params url.Values, host string) {
t.Helper() t.Helper()
if host != "www.google.de" { if host != "www.google.de" {
t.Fatalf("unexpected host: %s", host) t.Fatalf("unexpected host: %s", host)
} }
if got := params.Get("num"); got != "" {
t.Fatalf("num should be omitted when Limit<=10, got %q", got)
}
if got := params.Get("hl"); got != "de" { if got := params.Get("hl"); got != "de" {
t.Fatalf("unexpected hl value: %q", got) t.Fatalf("unexpected hl value: %q", got)
} }
@@ -316,12 +320,16 @@ func TestBuildImageSearchURL(t *testing.T) {
Text: "mountains", Text: "mountains",
LangCode: "en", LangCode: "en",
Region: "GB", Region: "GB",
Limit: 10,
}, },
check: func(t *testing.T, params url.Values, host string) { check: func(t *testing.T, params url.Values, host string) {
t.Helper() t.Helper()
if host != "www.google.co.uk" { if host != "www.google.co.uk" {
t.Fatalf("unexpected host: %s", host) t.Fatalf("unexpected host: %s", host)
} }
if got := params.Get("num"); got != "" {
t.Fatalf("num should be omitted when Limit<=10, got %q", got)
}
if got := params.Get("gl"); got != "gb" { if got := params.Get("gl"); got != "gb" {
t.Fatalf("unexpected gl value: %q", got) t.Fatalf("unexpected gl value: %q", got)
} }

View File

@@ -270,7 +270,7 @@ func BuildURL(q core.Query) (string, error) {
} }
// Limit number of results // Limit number of results
if q.Limit != 0 { if q.Limit > 10 {
params.Add("num", strconv.Itoa(q.Limit)) params.Add("num", strconv.Itoa(q.Limit))
} }
@@ -352,7 +352,7 @@ func BuildImageURL(q core.Query) (string, error) {
} }
// Limit number of results // Limit number of results
if q.Limit != 0 { if q.Limit > 10 {
params.Add("num", strconv.Itoa(q.Limit)) params.Add("num", strconv.Itoa(q.Limit))
} }

View File

@@ -243,7 +243,7 @@ func (yand *Yandex) Search(ctx context.Context, query core.Query) (results []cor
return false, nil return false, nil
} }
for !core.OrganicLimitReached(allResults, query.Limit) { for core.ShouldFetchResultPage(core.CountOrganicResults(allResults), query.Limit, searchPage-startPage) {
done, err := fetchPage() done, err := fetchPage()
if err != nil { if err != nil {
// Yandex commonly challenges rapid pagination, so a later page can // Yandex commonly challenges rapid pagination, so a later page can
@@ -257,7 +257,7 @@ func (yand *Yandex) Search(ctx context.Context, query core.Query) (results []cor
break break
} }
searchPage++ searchPage++
if done || core.OrganicLimitReached(allResults, query.Limit) { if done || !core.ShouldFetchResultPage(core.CountOrganicResults(allResults), query.Limit, searchPage-startPage) {
break break
} }
if err := core.SleepContext(ctx, yand.pageSleep); err != nil { if err := core.SleepContext(ctx, yand.pageSleep); err != nil {
@@ -281,7 +281,6 @@ func (yand *Yandex) SearchImage(ctx context.Context, query core.Query) ([]core.S
yand.logger.Debug("Starting image search, query: %+v", query) yand.logger.Debug("Starting image search, query: %+v", query)
searchResults := []core.SearchResult{} searchResults := []core.SearchResult{}
allowPagination := query.Limit > 30
searchPage := 0 searchPage := 0
// fetchPage loads one image page and appends parsed results. // fetchPage loads one image page and appends parsed results.
@@ -343,22 +342,19 @@ func (yand *Yandex) SearchImage(ctx context.Context, query core.Query) ([]core.S
} }
searchResults = append(searchResults, res) searchResults = append(searchResults, res)
} if query.Limit > 0 && len(searchResults) >= query.Limit {
if len(searchResults) >= query.Limit { return true, nil
return true, nil }
}
if searchPage == 1 && !allowPagination {
return true, nil
} }
return false, nil return false, nil
} }
for len(searchResults) < query.Limit { for core.ShouldFetchResultPage(len(searchResults), query.Limit, searchPage) {
done, err := fetchPage() done, err := fetchPage()
if err != nil { if err != nil {
return searchResults, err return searchResults, err
} }
if done { if done || !core.ShouldFetchResultPage(len(searchResults), query.Limit, searchPage) {
break break
} }
} }