mirror of
https://github.com/karust/openserp.git
synced 2026-08-18 22:28:21 +08:00
feat(api): v2 response shape - absolute rank, ad/organic split, slimmer fields
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
104
core/result_rank_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user