feat(api): v2 response shape - absolute rank, ad/organic split, slimmer fields

This commit is contained in:
Rustem Kamalov
2026-05-13 01:30:54 +03:00
parent 00630a5d31
commit b3ca4e0803
36 changed files with 972 additions and 279 deletions

View File

@@ -95,9 +95,11 @@ func classifyProxyNetworkError(err error) error {
// SearchResult represents one normalized result item returned by any engine.
type SearchResult struct {
// Rank is a 1-based position in engine output. Some engines use negative
// ranks for non-organic blocks such as ads or instant answers.
// Rank is the 1-based position within this result type. For SEO callers,
// organic rank must not be shifted by ads.
Rank int `json:"rank"`
// AbsoluteRank is the 1-based position in the mixed SERP stream.
AbsoluteRank int `json:"absolute_rank,omitempty"`
// URL is the canonical result URL.
URL string `json:"url"`
// Title is the result headline shown on the SERP.
@@ -118,14 +120,15 @@ func DeduplicateResults(results []SearchResult) []SearchResult {
if result.URL == "" {
continue
}
if !unique[result.URL] {
unique[result.URL] = true
key := resultDedupKey(result)
if !unique[key] {
unique[key] = true
deduped = append(deduped, result)
}
}
sort.Slice(deduped, func(i, j int) bool {
return deduped[i].Rank < deduped[j].Rank
return resultLess(deduped[i], deduped[j])
})
return deduped
}
@@ -140,11 +143,79 @@ func ConvertSearchResultsMap(searchResultsMap map[string]SearchResult) *[]Search
}
sort.Slice(searchResults, func(i, j int) bool {
return searchResults[i].Rank < searchResults[j].Rank
return resultLess(searchResults[i], searchResults[j])
})
return &searchResults
}
// CountOrganicResults returns the number of non-ad results in a mixed SERP.
func CountOrganicResults(results []SearchResult) int {
count := 0
for _, result := range results {
if !result.Ad {
count++
}
}
return count
}
// LimitOrganicResults keeps all ads and at most limit non-ad results.
func LimitOrganicResults(results []SearchResult, limit int) []SearchResult {
if limit <= 0 {
return results
}
out := make([]SearchResult, 0, len(results))
organicCount := 0
for _, result := range results {
if result.Ad {
out = append(out, result)
continue
}
if organicCount >= limit {
continue
}
organicCount++
out = append(out, result)
}
return out
}
func resultDedupKey(result SearchResult) string {
resultType := "organic"
if result.Ad {
resultType = "ad"
}
return resultType + "\x00" + result.URL
}
func resultLess(left, right SearchResult) bool {
leftPos := resultSortPosition(left)
rightPos := resultSortPosition(right)
if leftPos != rightPos {
return leftPos < rightPos
}
if left.Ad != right.Ad {
return left.Ad
}
if left.Rank != right.Rank {
return left.Rank < right.Rank
}
return left.URL < right.URL
}
func resultSortPosition(result SearchResult) int {
if result.AbsoluteRank > 0 {
return result.AbsoluteRank
}
if result.Rank < 0 {
return -result.Rank
}
if result.Rank > 0 {
return result.Rank
}
return int(^uint(0) >> 1)
}
// Query holds request parameters used by HTTP handlers and search engines.
// Example minimal query: Query{Text: "golang", Limit: 10}.
type Query struct {
@@ -168,7 +239,7 @@ type Query struct {
// For Google, false includes similar results and true hides them.
Filter bool
// Answers enables parsing answer modules when supported by the engine.
// Such entries may be returned with negative rank values.
// Such entries may be returned with non-positive internal rank values.
Answers bool
// ProxyURL is a direct proxy URL used by raw HTTP search paths.
ProxyURL string

View File

@@ -45,15 +45,9 @@ func EnrichDomainInfo(domain string) *DomainInfo {
cfg := loadEnrichmentDomains()
info := &DomainInfo{
TLD: tld,
SLD: sld,
IsGov: isGovTLD(domain, tld),
IsEdu: isEduTLD(domain, tld),
IsMil: isMilTLD(tld),
IsNews: cfg.NewsDomains[domain],
IsForum: cfg.ForumDomains[domain],
IsMarketplace: cfg.MarketplaceDomains[domain],
IsSocial: cfg.SocialDomains[domain],
TLD: tld,
SLD: sld,
Category: domainCategory(domain, tld, cfg),
}
return info
}
@@ -67,6 +61,9 @@ func ClassifyURL(rawURL, domain string) *Classification {
contentType := classifyContentType(rawURL)
sourceHint := classifySourceHint(domain)
if contentType == "webpage" && sourceHint == "" {
return nil
}
return &Classification{
ContentType: contentType,
@@ -74,6 +71,27 @@ func ClassifyURL(rawURL, domain string) *Classification {
}
}
func domainCategory(domain, tld string, cfg enrichmentDomainsConfig) string {
switch {
case isGovTLD(domain, tld):
return "gov"
case isEduTLD(domain, tld):
return "edu"
case isMilTLD(tld):
return "mil"
case cfg.NewsDomains[domain]:
return "news"
case cfg.ForumDomains[domain]:
return "forum"
case cfg.MarketplaceDomains[domain]:
return "marketplace"
case cfg.SocialDomains[domain]:
return "social"
default:
return ""
}
}
// splitDomain returns (public suffix, registrable domain label).
func splitDomain(domain string) (tld, sld string) {
domain = normalizeDomain(domain)

View File

@@ -22,9 +22,6 @@ func RenderMarkdown(env *Envelope) []byte {
for i, r := range env.Results {
fmt.Fprintf(&b, "## %d. %s\n\n", i+1, escapeMarkdown(r.Title))
typeLabel := string(r.Type)
if r.IsAd {
typeLabel = "ad"
}
fmt.Fprintf(&b, "**%s** · %s\n\n", r.DisplayURL, typeLabel)
if r.Snippet != "" {
fmt.Fprintf(&b, "%s\n\n", r.Snippet)

View File

@@ -11,13 +11,13 @@ type QueryEcho struct {
// ResponseMeta carries request-level metadata for observability and debugging.
type ResponseMeta struct {
RequestID string `json:"request_id"`
RequestedAt string `json:"requested_at"`
TookMs int64 `json:"took_ms"`
EnginesResponded []string `json:"engines_responded,omitempty"`
EnginesFailed []string `json:"engines_failed"`
EngineErrors []EngineErrorDetail `json:"engine_errors,omitempty"`
Version string `json:"version"`
RequestID string `json:"request_id"`
RequestedAt string `json:"requested_at"`
TookMs int64 `json:"took_ms"`
EnginesResponded []string `json:"engines_responded,omitempty"`
EnginesFailed []string `json:"engines_failed"`
EngineErrors []EngineErrorDetail `json:"engine_errors,omitempty"`
Version string `json:"version"`
}
// EngineErrorDetail is a client-facing, sanitized per-engine failure summary.
@@ -34,7 +34,7 @@ type Pagination struct {
NextStart int `json:"next_start"`
}
// Envelope is the top-level v1 response wrapper for all search endpoints.
// Envelope is the top-level v2 response wrapper for all search endpoints.
type Envelope struct {
Query QueryEcho `json:"query"`
Meta ResponseMeta `json:"meta"`
@@ -64,7 +64,7 @@ type ClusterOccurrence struct {
ResultID string `json:"result_id"`
}
// ImageEnvelope is the top-level v1 response wrapper for image search endpoints.
// ImageEnvelope is the top-level v2 response wrapper for image search endpoints.
type ImageEnvelope struct {
Query QueryEcho `json:"query"`
Meta ResponseMeta `json:"meta"`
@@ -72,7 +72,7 @@ type ImageEnvelope struct {
Pagination Pagination `json:"pagination"`
}
const apiVersion = "1.0"
const apiVersion = "2.0"
// NewEnvelope builds a fresh Envelope pre-filled with query echo and an open
// meta block. Call Finalize before serializing.
@@ -124,11 +124,21 @@ func (e *Envelope) Finalize(startedAt time.Time, q Query) {
page := q.Start/limit + 1
e.Pagination = Pagination{
Page: page,
HasMore: len(e.Results) >= limit,
HasMore: countNonAdResults(e.Results) >= limit,
NextStart: q.Start + limit,
}
}
func countNonAdResults(results []Result) int {
count := 0
for _, result := range results {
if result.Type != ResultTypeAd {
count++
}
}
return count
}
// Finalize stamps the elapsed time and computes pagination fields.
func (e *ImageEnvelope) Finalize(startedAt time.Time, q Query) {
e.Meta.TookMs = time.Since(startedAt).Milliseconds()

View File

@@ -24,7 +24,7 @@ type EnrichContext struct {
Query Query
}
// EnrichResult converts a raw engine result into the v1 Result shape.
// EnrichResult converts a raw engine result into the v2 Result shape.
func EnrichResult(raw SearchResult, ctx EnrichContext) Result {
normalizedURL := normalizeURL(raw.URL)
domain := extractDomain(normalizedURL)
@@ -42,17 +42,23 @@ func EnrichResult(raw SearchResult, ctx EnrichContext) Result {
if raw.Rank <= 0 && !raw.Ad {
resultType = ResultTypeAnswerBox
}
limit := ctx.Query.Limit
if limit <= 0 {
limit = 25
rank := raw.Rank
if rank < 0 {
if raw.Ad {
rank = -rank
} else {
rank = 0
}
}
absolute := computeResultPosition(raw, ctx.Query.Start)
if absolute <= 0 {
absolute = rank
}
page := ctx.Query.Start/limit + 1
absolute, onPage := computeResultPosition(raw.Rank, ctx.Query.Start)
result := Result{
ID: buildResultID(ctx.Engine, normalizedURL),
Rank: raw.Rank,
Rank: rank,
Type: resultType,
Title: raw.Title,
URL: normalizedURL,
@@ -60,13 +66,10 @@ func EnrichResult(raw SearchResult, ctx EnrichContext) Result {
Snippet: raw.Description,
Domain: domain,
Favicon: favicon,
IsAd: raw.Ad,
Position: Position{
Absolute: absolute,
Page: page,
OnPage: onPage,
},
Engine: ctx.Engine,
Engine: ctx.Engine,
}
if absolute > 0 {
result.Position = &Position{Absolute: absolute}
}
result.DomainInfo = EnrichDomainInfo(domain)
@@ -75,7 +78,7 @@ func EnrichResult(raw SearchResult, ctx EnrichContext) Result {
return result
}
// EnrichImageResult converts a raw engine result into the v1 ImageResult shape.
// EnrichImageResult converts a raw engine result into the v2 ImageResult shape.
func EnrichImageResult(raw SearchResult, ctx EnrichContext) ImageResult {
imageURL := normalizeURL(raw.URL)
meta := parseImageDescription(raw.Description)
@@ -122,14 +125,22 @@ func shortMD5(value string) string {
return hex.EncodeToString(h[:responseIDBytes])
}
func computeResultPosition(rank, start int) (absolute, onPage int) {
if rank <= 0 {
return 0, 0
func computeResultPosition(raw SearchResult, start int) int {
if raw.AbsoluteRank > 0 {
return raw.AbsoluteRank
}
rank := raw.Rank
if rank < 0 {
rank = -rank
}
if rank == 0 {
return 0
}
if start > 0 && rank > start {
return rank, rank - start
return rank
}
return start + rank, rank
return start + rank
}
// normalizeURL lowercases scheme+host, strips trailing slash, and removes

View File

@@ -19,34 +19,29 @@ const (
// Position describes where a result sits in the overall result stream.
type Position struct {
// Absolute is the 1-based rank counting from the first result of the first page.
// Absolute is the 1-based rank counting from the first result of the first page,
// across both organic and ad blocks. Always emitted so SEO callers can plot
// rank vs. on-page position without inferring it from the result order.
Absolute int `json:"absolute"`
// Page is the 1-based page number derived from start/limit.
Page int `json:"page"`
// OnPage is the 1-based rank within this page.
OnPage int `json:"on_page"`
}
// DomainInfo carries TLD-derived category signals for a result domain.
type DomainInfo struct {
TLD string `json:"tld"`
SLD string `json:"sld"`
IsGov bool `json:"is_gov"`
IsEdu bool `json:"is_edu"`
IsMil bool `json:"is_mil"`
IsNews bool `json:"is_news"`
IsForum bool `json:"is_forum"`
IsMarketplace bool `json:"is_marketplace"`
IsSocial bool `json:"is_social"`
TLD string `json:"tld,omitempty"`
SLD string `json:"sld,omitempty"`
// Category is one of "gov", "edu", "mil", "news", "forum", "marketplace",
// "social", or "" when the domain does not match any known category.
Category string `json:"category"`
}
// Classification holds URL-path heuristic hints for downstream consumers.
type Classification struct {
ContentType string `json:"content_type"`
SourceHint string `json:"source_hint"`
ContentType string `json:"content_type,omitempty"`
SourceHint string `json:"source_hint,omitempty"`
}
// Result is the v1 normalized result returned in every search response.
// Result is the v2 normalized result returned in search responses. Optional
// fields (Position, DomainInfo, Classification) are omitted when empty.
type Result struct {
ID string `json:"id"`
Rank int `json:"rank"`
@@ -57,8 +52,7 @@ type Result struct {
Snippet string `json:"snippet"`
Domain string `json:"domain"`
Favicon string `json:"favicon"`
IsAd bool `json:"is_ad"`
Position Position `json:"position"`
Position *Position `json:"position,omitempty"`
Engine string `json:"engine"`
DomainInfo *DomainInfo `json:"domain_info,omitempty"`
Classification *Classification `json:"classification,omitempty"`
@@ -78,7 +72,7 @@ type ImageSource struct {
Domain string `json:"domain"`
}
// ImageResult is the v1 shape for image search results.
// ImageResult is the v2 shape for image search results.
type ImageResult struct {
ID string `json:"id"`
Rank int `json:"rank"`

104
core/result_rank_test.go Normal file
View File

@@ -0,0 +1,104 @@
package core
import (
"testing"
"time"
)
func TestDeduplicateResultsOrdersByAbsoluteRank(t *testing.T) {
t.Parallel()
results := DeduplicateResults([]SearchResult{
{Rank: 1, AbsoluteRank: 3, URL: "https://organic.example.com/one"},
{Rank: 1, AbsoluteRank: 1, URL: "https://ads.example.com/one", Ad: true},
{Rank: 2, AbsoluteRank: 2, URL: "https://ads.example.com/two", Ad: true},
{Rank: 2, AbsoluteRank: 4, URL: "https://organic.example.com/two"},
})
wantRanks := []int{1, 2, 1, 2}
wantAbsoluteRanks := []int{1, 2, 3, 4}
if len(results) != len(wantRanks) {
t.Fatalf("len(results) = %d, want %d", len(results), len(wantRanks))
}
for i, want := range wantRanks {
if results[i].Rank != want {
t.Fatalf("result %d rank = %d, want %d", i, results[i].Rank, want)
}
if results[i].AbsoluteRank != wantAbsoluteRanks[i] {
t.Fatalf("result %d absolute rank = %d, want %d", i, results[i].AbsoluteRank, wantAbsoluteRanks[i])
}
}
}
func TestDeduplicateResultsKeepsAdAndOrganicForSameURL(t *testing.T) {
t.Parallel()
results := DeduplicateResults([]SearchResult{
{Rank: 1, AbsoluteRank: 1, URL: "https://example.com/page", Ad: true},
{Rank: 1, AbsoluteRank: 2, URL: "https://example.com/page"},
})
if len(results) != 2 {
t.Fatalf("len(results) = %d, want 2", len(results))
}
if !results[0].Ad || results[1].Ad {
t.Fatalf("expected ad and organic rows to be preserved separately: %+v", results)
}
}
func TestLimitOrganicResultsDoesNotCountAds(t *testing.T) {
t.Parallel()
results := LimitOrganicResults([]SearchResult{
{Rank: 1, URL: "https://ads.example.com/one", Ad: true},
{Rank: 1, URL: "https://organic.example.com/one"},
{Rank: 2, URL: "https://ads.example.com/two", Ad: true},
{Rank: 2, URL: "https://organic.example.com/two"},
}, 1)
if len(results) != 3 {
t.Fatalf("len(results) = %d, want 3", len(results))
}
if CountOrganicResults(results) != 1 {
t.Fatalf("organic count = %d, want 1", CountOrganicResults(results))
}
}
func TestEnrichResultUsesAdRankAndAbsolutePosition(t *testing.T) {
t.Parallel()
result := EnrichResult(SearchResult{
Rank: 1,
AbsoluteRank: 2,
URL: "https://ads.example.com/",
Title: "Ad",
Ad: true,
}, EnrichContext{Engine: "google", Query: Query{Limit: 10}})
if result.Rank != 1 {
t.Fatalf("rank = %d, want 1", result.Rank)
}
if result.Position == nil || result.Position.Absolute != 2 {
t.Fatalf("unexpected position: %+v", result.Position)
}
if result.Type != ResultTypeAd {
t.Fatalf("unexpected result type: %q", result.Type)
}
}
func TestEnvelopePaginationCountsOrganicResults(t *testing.T) {
t.Parallel()
env := NewEnvelope(Query{Text: "ads", Limit: 2}, "req-1", time.Now(), []string{"bing"})
env.Results = []Result{
{Rank: 1, Type: ResultTypeAd},
{Rank: 2, Type: ResultTypeAd},
{Rank: 1, Type: ResultTypeOrganic},
}
env.Finalize(time.Now(), Query{Text: "ads", Limit: 2})
if env.Pagination.HasMore {
t.Fatalf("has_more = true, want false when organic count is below limit: %+v", env.Pagination)
}
}

View File

@@ -1198,10 +1198,11 @@ func (s *Server) deduplicateMegaResults(results []MegaSearchResult) []MegaSearch
if result.URL == "" {
continue
}
key := NormalizeURLForClustering(result.URL)
if key == "" {
normalizedURL := NormalizeURLForClustering(result.URL)
if normalizedURL == "" {
continue
}
key := resultDedupKey(SearchResult{URL: normalizedURL, Ad: result.Ad})
existing, exists := urlMap[key]
if !exists {
urlMap[key] = result
@@ -1219,8 +1220,11 @@ func (s *Server) deduplicateMegaResults(results []MegaSearchResult) []MegaSearch
}
sort.Slice(deduped, func(i, j int) bool {
if deduped[i].Rank != deduped[j].Rank {
return deduped[i].Rank < deduped[j].Rank
if resultLess(deduped[i].SearchResult, deduped[j].SearchResult) {
return true
}
if resultLess(deduped[j].SearchResult, deduped[i].SearchResult) {
return false
}
if deduped[i].Engine != deduped[j].Engine {
return deduped[i].Engine < deduped[j].Engine

View File

@@ -2208,7 +2208,7 @@ func TestServerOptions_DisableCORSMiddleware(t *testing.T) {
}
}
func TestDedicatedEndpointReturnsV1Envelope(t *testing.T) {
func TestDedicatedEndpointReturnsV2Envelope(t *testing.T) {
engine := &engineMock{name: "google", initialized: true}
opts := DefaultServerOptions()
opts.Resilience.Retry.MaxRetries = 0
@@ -2223,8 +2223,8 @@ func TestDedicatedEndpointReturnsV1Envelope(t *testing.T) {
if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
t.Fatalf("decode envelope: %v", err)
}
if env.Meta.Version != "1.0" {
t.Fatalf("expected meta.version=1.0, got %q", env.Meta.Version)
if env.Meta.Version != "2.0" {
t.Fatalf("expected meta.version=2.0, got %q", env.Meta.Version)
}
if env.Meta.RequestID == "" {
t.Fatal("expected non-empty meta.request_id")
@@ -2248,15 +2248,12 @@ func TestDedicatedEndpointReturnsV1Envelope(t *testing.T) {
if r.Snippet == "" && r.Title == "" {
t.Fatal("expected result to have title or snippet")
}
if r.Position.Page < 1 {
t.Fatalf("expected position.page >= 1, got %d", r.Position.Page)
}
if env.Pagination.Page < 1 {
t.Fatalf("expected pagination.page >= 1, got %d", env.Pagination.Page)
}
}
func TestMegaSearchReturnsV1EnvelopeWithEnginesFailed(t *testing.T) {
func TestMegaSearchReturnsV2EnvelopeWithEnginesFailed(t *testing.T) {
good := &engineMock{name: "google", initialized: true}
bad := &engineMock{
name: "bing",
@@ -2279,8 +2276,8 @@ func TestMegaSearchReturnsV1EnvelopeWithEnginesFailed(t *testing.T) {
if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
t.Fatalf("decode envelope: %v", err)
}
if env.Meta.Version != "1.0" {
t.Fatalf("expected meta.version=1.0, got %q", env.Meta.Version)
if env.Meta.Version != "2.0" {
t.Fatalf("expected meta.version=2.0, got %q", env.Meta.Version)
}
if len(env.Meta.EnginesFailed) == 0 {
t.Fatal("expected engines_failed to contain bing")
@@ -2446,6 +2443,49 @@ func TestFormatParamReturnsCorrectContentType(t *testing.T) {
}
}
func TestJSONOmitsRedundantResultFields(t *testing.T) {
engine := &engineMock{
name: "google",
initialized: true,
searchFn: func(_ context.Context, _ Query) ([]SearchResult, error) {
return []SearchResult{{
Rank: 1,
AbsoluteRank: 2,
URL: "https://example.gov/page",
Title: "Gov Result",
Description: "Snippet",
}}, nil
},
}
opts := DefaultServerOptions()
opts.Resilience.Retry.MaxRetries = 0
srv := NewServerWithOptions("127.0.0.1", 7210, opts, engine)
resp := request(t, srv, "/google/search?text=clean-json")
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
payload := string(body)
for _, omitted := range []string{`"is_ad"`, `"on_page"`, `"is_gov"`} {
if strings.Contains(payload, omitted) {
t.Fatalf("json payload should not contain %s: %s", omitted, payload)
}
}
if strings.Contains(payload, `"position":{"absolute":2,"page"`) {
t.Fatalf("json payload should not contain per-result page: %s", payload)
}
if !strings.Contains(payload, `"type":"organic"`) {
t.Fatalf("json payload should keep result type: %s", payload)
}
if !strings.Contains(payload, `"category":"gov"`) {
t.Fatalf("json payload should collapse domain category: %s", payload)
}
if !strings.Contains(payload, `"absolute":2`) {
t.Fatalf("json payload should keep meaningful absolute position: %s", payload)
}
}
func TestFormatParamBypassesJSONCache(t *testing.T) {
engine := &engineMock{name: "google", initialized: true}
opts := DefaultServerOptions()
@@ -2531,9 +2571,8 @@ func TestPaginatedPositionUsesAbsoluteRank(t *testing.T) {
if len(env.Results) != 1 {
t.Fatalf("expected one result, got %d", len(env.Results))
}
pos := env.Results[0].Position
if pos.Absolute != 11 || pos.OnPage != 1 || pos.Page != 2 {
t.Fatalf("unexpected position: %+v", pos)
if env.Results[0].Position == nil || env.Results[0].Position.Absolute != 11 {
t.Fatalf("expected absolute position 11, got %+v", env.Results[0].Position)
}
}