package contracts import ( "bytes" "encoding/json" ) // MainResult represents one element of the `results` array. // // The API returns many additional keys beyond what templates use. To keep the // contract stable for proxying/merging, we preserve all unknown keys in // `raw` and re-emit them via MarshalJSON. type MainResult struct { raw map[string]any // Common fields used by templates (RSS uses: title, url, content, pubdate). Template string `json:"template"` Title string `json:"title"` Content string `json:"content"` URL *string `json:"url"` Pubdate *string `json:"pubdate"` Engine string `json:"engine"` Score float64 `json:"score"` Category string `json:"category"` Priority string `json:"priority"` Positions []int `json:"positions"` Engines []string `json:"engines"` // These fields exist in the MainResult base; keep them so downstream // callers can generate richer output later. OpenGroup bool `json:"open_group"` CloseGroup bool `json:"close_group"` // parsed_url is emitted as a tuple; we preserve it as-is. ParsedURL any `json:"parsed_url"` } func (mr *MainResult) UnmarshalJSON(data []byte) error { // Preserve the full object. dec := json.NewDecoder(bytes.NewReader(data)) dec.UseNumber() var m map[string]any if err := dec.Decode(&m); err != nil { return err } mr.raw = m // Fill the typed/common fields (best-effort; don't fail if types differ). mr.Template = stringOrEmpty(m["template"]) mr.Title = stringOrEmpty(m["title"]) mr.Content = stringOrEmpty(m["content"]) mr.Engine = stringOrEmpty(m["engine"]) mr.Category = stringOrEmpty(m["category"]) mr.Priority = stringOrEmpty(m["priority"]) if s, ok := stringOrNullable(m["url"]); ok { mr.URL = &s } if s, ok := stringOrNullable(m["pubdate"]); ok { mr.Pubdate = &s } mr.Score = floatOrZero(m["score"]) if v, ok := sliceOfStrings(m["engines"]); ok { mr.Engines = v } if v, ok := sliceOfInts(m["positions"]); ok { mr.Positions = v } if v, ok := boolOrFalse(m["open_group"]); ok { mr.OpenGroup = v } if v, ok := boolOrFalse(m["close_group"]); ok { mr.CloseGroup = v } mr.ParsedURL = m["parsed_url"] return nil } func (mr MainResult) MarshalJSON() ([]byte, error) { // If we came from upstream JSON, preserve all keys exactly. if mr.raw != nil { return json.Marshal(mr.raw) } // Otherwise, marshal the known fields. m := map[string]any{ "template": mr.Template, "title": mr.Title, "content": mr.Content, "url": mr.URL, "pubdate": mr.Pubdate, "engine": mr.Engine, "score": mr.Score, "category": mr.Category, "priority": mr.Priority, "positions": mr.Positions, "engines": mr.Engines, "open_group": mr.OpenGroup, "close_group": mr.CloseGroup, "parsed_url": mr.ParsedURL, } return json.Marshal(m) } func stringOrEmpty(v any) string { s, _ := v.(string) return s } func stringOrNullable(v any) (string, bool) { if v == nil { return "", false } s, ok := v.(string) return s, ok } func floatOrZero(v any) float64 { switch t := v.(type) { case float64: return t case float32: return float64(t) case int: return float64(t) case int64: return float64(t) case json.Number: f, _ := t.Float64() return f default: return 0 } } func boolOrFalse(v any) (bool, bool) { b, ok := v.(bool) if !ok { return false, false } return b, true } func sliceOfStrings(v any) ([]string, bool) { raw, ok := v.([]any) if !ok { return nil, false } out := make([]string, 0, len(raw)) for _, item := range raw { s, ok := item.(string) if !ok { return nil, false } out = append(out, s) } return out, true } func sliceOfInts(v any) ([]int, bool) { raw, ok := v.([]any) if !ok { return nil, false } out := make([]int, 0, len(raw)) for _, item := range raw { switch t := item.(type) { case float64: out = append(out, int(t)) case int: out = append(out, t) case json.Number: i64, err := t.Int64() if err != nil { return nil, false } out = append(out, int(i64)) default: return nil, false } } return out, true }