diff --git a/core/common.go b/core/common.go index 9b47187..3892110 100644 --- a/core/common.go +++ b/core/common.go @@ -315,6 +315,11 @@ func (q Query) IsEmpty() bool { // MaxQueryLimit is the maximum allowed value for the limit parameter. const MaxQueryLimit = 100 +// defaultQueryLimit is the assumed limit when a request omits it (InitFromContext) +// and the fallback used by pagination math for internally-built queries that +// leave Limit unset. +const defaultQueryLimit = 10 + // InitFromContext populates Query from HTTP query parameters and request // headers. It validates numeric/boolean inputs and returns an *APIError for // invalid client input (400) or a plain error for internal failures. @@ -326,7 +331,7 @@ func (searchQuery *Query) InitFromContext(reqCtx *fiber.Ctx) error { searchQuery.Filetype = strings.TrimSpace(reqCtx.Query("file")) searchQuery.Site = strings.TrimSpace(reqCtx.Query("site")) - limitRaw := reqCtx.Query("limit", "10") + limitRaw := reqCtx.Query("limit", strconv.Itoa(defaultQueryLimit)) limit, err := strconv.Atoi(limitRaw) if err != nil { return errInvalidLimit("limit must be an integer") diff --git a/core/format_markdown.go b/core/format_markdown.go index 0cd362c..bfea18f 100644 --- a/core/format_markdown.go +++ b/core/format_markdown.go @@ -34,7 +34,7 @@ func RenderMarkdown(env *Envelope) []byte { fmt.Fprintf(&b, "-> %s\n\n", r.URL) } - renderMarkdownFeatures(&b, env.SerpFeatures, featureRenderOrderAfterResults()) + renderMarkdownFeatures(&b, env.SerpFeatures, featureRenderOrderAfterResults(env.SerpFeatures)) return []byte(b.String()) } @@ -59,14 +59,9 @@ func RenderMarkdownImage(env *ImageEnvelope) []byte { } func renderMarkdownFeatures(b *strings.Builder, features []SerpFeature, order []ResultType) { - for _, featureType := range order { - for _, feature := range features { - if feature.Type != featureType { - continue - } - renderMarkdownFeature(b, feature) - } - } + forEachFeatureInOrder(features, order, func(feature SerpFeature) { + renderMarkdownFeature(b, feature) + }) } func renderMarkdownFeature(b *strings.Builder, feature SerpFeature) { diff --git a/core/format_text.go b/core/format_text.go index ef17617..3adf6ff 100644 --- a/core/format_text.go +++ b/core/format_text.go @@ -34,7 +34,7 @@ func RenderText(env *Envelope) []byte { fmt.Fprintf(&b, "URL: %s\n\n", r.URL) } - renderTextFeatures(&b, env.SerpFeatures, featureRenderOrderAfterResults()) + renderTextFeatures(&b, env.SerpFeatures, featureRenderOrderAfterResults(env.SerpFeatures)) return []byte(b.String()) } @@ -76,12 +76,20 @@ func RenderNDJSONImage(env *ImageEnvelope) []byte { } func renderTextFeatures(b *strings.Builder, features []SerpFeature, order []ResultType) { + forEachFeatureInOrder(features, order, func(feature SerpFeature) { + renderTextFeature(b, feature) + }) +} + +// forEachFeatureInOrder invokes render for every feature whose Type appears in +// order, type by type. It is the single iteration shared by the text and +// markdown renderers. +func forEachFeatureInOrder(features []SerpFeature, order []ResultType, render func(SerpFeature)) { for _, featureType := range order { for _, feature := range features { - if feature.Type != featureType { - continue + if feature.Type == featureType { + render(feature) } - renderTextFeature(b, feature) } } } @@ -151,9 +159,12 @@ func featureRenderOrderBeforeResults() []ResultType { } // featureRenderOrderAfterResults lists the feature sections rendered below the -// results list (related searches and the module gallery), in fixed order. -func featureRenderOrderAfterResults() []ResultType { - return []ResultType{ +// results list (related searches and the module gallery), in fixed order. Any +// feature type present in features but absent from both fixed orders is appended +// at the end, so a newly added feature enum is never silently dropped from text +// or markdown output. +func featureRenderOrderAfterResults(features []SerpFeature) []ResultType { + order := []ResultType{ ResultTypeRelatedSearches, ResultTypeNews, ResultTypeVideo, @@ -166,6 +177,20 @@ func featureRenderOrderAfterResults() []ResultType { ResultTypeWeather, ResultTypeDictionary, } + placed := make(map[ResultType]bool, len(order)+len(featureRenderOrderBeforeResults())) + for _, t := range featureRenderOrderBeforeResults() { + placed[t] = true + } + for _, t := range order { + placed[t] = true + } + for _, feature := range features { + if !placed[feature.Type] { + order = append(order, feature.Type) + placed[feature.Type] = true + } + } + return order } func featureHeading(feature SerpFeature) string { diff --git a/core/response.go b/core/response.go index f5af1c8..db384ba 100644 --- a/core/response.go +++ b/core/response.go @@ -124,7 +124,7 @@ func (e *Envelope) Finalize(startedAt time.Time, q Query) { limit := q.Limit if limit <= 0 { - limit = 25 + limit = defaultQueryLimit } page := q.Start/limit + 1 e.Pagination = Pagination{ @@ -150,7 +150,7 @@ func (e *ImageEnvelope) Finalize(startedAt time.Time, q Query) { limit := q.Limit if limit <= 0 { - limit = 25 + limit = defaultQueryLimit } page := q.Start/limit + 1 e.Pagination = Pagination{ diff --git a/core/serp_features_test.go b/core/serp_features_test.go index d153f83..659f630 100644 --- a/core/serp_features_test.go +++ b/core/serp_features_test.go @@ -150,3 +150,25 @@ func TestRenderersIncludeSerpFeatures(t *testing.T) { t.Fatalf("feature ndjson lines missing kind tag: %v", lines) } } + +// TestRenderFeaturesUnorderedTypeStillRenders guards against silently dropping a +// feature whose Type is in neither render-order section (e.g. a newly added +// enum value not yet placed). It must still appear in text and markdown output. +func TestRenderFeaturesUnorderedTypeStillRenders(t *testing.T) { + const unplaced ResultType = "experimental_module" + env := NewEnvelope(Query{Text: "openserp"}, "req-1", time.Unix(0, 0), []string{"google"}) + env.SerpFeatures = append(env.SerpFeatures, SerpFeature{ + Type: unplaced, + Text: "experimental feature body", + }) + + text := string(RenderText(env)) + if !strings.Contains(text, "experimental feature body") { + t.Fatalf("text output dropped an unordered feature type:\n%s", text) + } + + markdown := string(RenderMarkdown(env)) + if !strings.Contains(markdown, "experimental feature body") { + t.Fatalf("markdown output dropped an unordered feature type:\n%s", markdown) + } +} diff --git a/google/features.go b/google/features.go index c755302..0fc6770 100644 --- a/google/features.go +++ b/google/features.go @@ -104,7 +104,12 @@ func looksLikeCSS(text string) bool { return true } // CSS rule blocks look like "} .cls {" / "} #id {"; prose almost never does. - return strings.Contains(text, "} .") || strings.Contains(text, "} #") || strings.Contains(text, "{ ") && strings.Count(text, ": ") > 20 && strings.Count(text, ";") > 20 + if strings.Contains(text, "} .") || strings.Contains(text, "} #") { + return true + } + // A dense run of "prop: value;" declarations inside a "{ ... }" block is the + // other tell for inlined stylesheet text. + return strings.Contains(text, "{ ") && strings.Count(text, ": ") > 20 && strings.Count(text, ";") > 20 } func extractGoogleFeaturesFromPage(page *rod.Page) []core.SerpFeature {