// Package dedup provides deterministic normalization helpers for detecting // duplicate stories across feeds. package dedup import ( "net/url" "strings" "unicode" ) // trackingParams are query-string keys stripped during URL canonicalization. // Anything starting with "utm_" is also stripped. var trackingParams = map[string]struct{}{ "fbclid": {}, "gclid": {}, "mc_cid": {}, "mc_eid": {}, "ref": {}, "ref_src": {}, "ref_url": {}, "share": {}, "shared": {}, "cmpid": {}, "cmp": {}, "icid": {}, "ito": {}, "yclid": {}, "_ga": {}, "igshid": {}, "feature": {}, "si": {}, "smid": {}, "twclid": {}, "mibextid": {}, "src": {}, } // CanonicalURL returns a stable form of a story URL suitable for dedup. // It lowercases the scheme/host, drops fragments and common tracking params, // and trims trailing slashes. If parsing fails, the input is returned unchanged. func CanonicalURL(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { return "" } u, err := url.Parse(raw) if err != nil || u.Host == "" { return raw } u.Scheme = strings.ToLower(u.Scheme) u.Host = strings.ToLower(u.Host) u.Host = strings.TrimPrefix(u.Host, "www.") u.Host = strings.TrimPrefix(u.Host, "m.") u.Host = strings.TrimPrefix(u.Host, "amp.") u.Fragment = "" if q := u.Query(); len(q) > 0 { for k := range q { lk := strings.ToLower(k) if strings.HasPrefix(lk, "utm_") { q.Del(k) continue } if _, drop := trackingParams[lk]; drop { q.Del(k) } } u.RawQuery = q.Encode() } u.Path = strings.TrimRight(u.Path, "/") if u.Path == "" { u.Path = "/" } return u.String() } // NormalizeHeadline returns a comparable form of a headline: lowercase, // punctuation stripped, whitespace collapsed. Empty input returns "". func NormalizeHeadline(h string) string { if h == "" { return "" } var b strings.Builder b.Grow(len(h)) prevSpace := true for _, r := range h { switch { case unicode.IsLetter(r) || unicode.IsDigit(r): b.WriteRune(unicode.ToLower(r)) prevSpace = false case unicode.IsSpace(r) || unicode.IsPunct(r) || unicode.IsSymbol(r): if !prevSpace { b.WriteByte(' ') prevSpace = true } } } return strings.TrimSpace(b.String()) }