link-canonical-0.1.0.0: src/Link/Canonical/Rules/Amazon.hs
-- | Amazon URL normalization rule
--
-- == Canonical Form
--
-- @
-- https://www.amazon.{TLD}/dp/{ASIN}
-- @
--
-- Regional TLDs are preserved (e.g., amazon.co.uk, amazon.de).
--
-- == Handled Variants
--
-- * @amazon.com/dp/{ASIN}@
-- * @www.amazon.com/dp/{ASIN}@
-- * @amazon.com/gp/product/{ASIN}@
-- * @amazon.com/{product-name}/dp/{ASIN}@
-- * @smile.amazon.com/...@ (Amazon Smile)
--
-- Note: @amzn.to@ short URLs require redirect resolution and are
-- handled by the redirect phase, not this rule.
--
-- == Stripped Parameters
--
-- @tag@, @ref@, @psc@, @keywords@, @qid@, @sr@, @th@, @linkCode@, @camp@, @creative@
module Link.Canonical.Rules.Amazon
( amazonRule,
isAmazonUrl,
extractAsin,
amazonStrippedParams,
)
where
import Data.List (findIndex)
import Data.List.NonEmpty qualified as NE
import Data.Text qualified as T
import Link.Canonical.Prelude
import Link.Canonical.Rules.Types (DomainRule, domainRule, getHost)
import Text.URI qualified as URI
-- | Amazon domain rule
amazonRule :: DomainRule
amazonRule = domainRule "amazon" isAmazonUrl normalizeAmazon
-- | Parameters to strip from Amazon URLs
amazonStrippedParams :: [Text]
amazonStrippedParams =
[ "tag", -- affiliate tag
"ref", -- referral
"psc", -- product selection
"keywords", -- search keywords
"qid", -- query ID
"sr", -- search rank
"th", -- variation selector
"linkCode", -- affiliate link code
"camp", -- campaign
"creative" -- creative ID
]
-- | Amazon TLDs we recognize
amazonTlds :: [Text]
amazonTlds =
[ "amazon.com",
"amazon.co.uk",
"amazon.de",
"amazon.fr",
"amazon.it",
"amazon.es",
"amazon.ca",
"amazon.co.jp",
"amazon.com.au",
"amazon.com.br",
"amazon.com.mx",
"amazon.nl",
"amazon.sg",
"amazon.ae",
"amazon.sa",
"amazon.in",
"amazon.se",
"amazon.pl",
"amazon.com.tr",
"amazon.eg"
]
-- | Check if a URI is an Amazon URL
isAmazonUrl :: URI -> Bool
isAmazonUrl uri =
case getHost uri of
Nothing -> False
Just host ->
let lowerHost = T.toLower host
-- Strip www. or smile. prefix if present
baseHost = stripSubdomain lowerHost
in baseHost `elem` amazonTlds
-- | Strip common subdomains (www., smile.)
stripSubdomain :: Text -> Text
stripSubdomain host
| "www." `T.isPrefixOf` host = T.drop 4 host
| "smile." `T.isPrefixOf` host = T.drop 6 host
| otherwise = host
-- | Normalize an Amazon URL to canonical form
normalizeAmazon :: URI -> URI
normalizeAmazon uri =
case extractAsin uri of
Nothing -> uri -- Can't extract ASIN, return as-is
Just asinId -> buildCanonicalAmazon uri asinId
-- | Extract the ASIN from various Amazon URL formats
--
-- ASINs are 10-character alphanumeric identifiers.
extractAsin :: URI -> Maybe Text
extractAsin uri = do
(_, pathSegments) <- URI.uriPath uri
let segments = NE.toList pathSegments
segTexts = map URI.unRText segments
extractAsinFromPath segTexts
-- | Extract ASIN from path segments
--
-- Handles:
-- * /dp/{ASIN}
-- * /gp/product/{ASIN}
-- * /{product-name}/dp/{ASIN}
extractAsinFromPath :: [Text] -> Maybe Text
extractAsinFromPath segments =
extractFromDp segments
<|> extractFromGpProduct segments
-- | Extract from /dp/{ASIN} or /{name}/dp/{ASIN}
extractFromDp :: [Text] -> Maybe Text
extractFromDp segments =
case findDpIndex segments of
Nothing -> Nothing
Just idx ->
let afterDp = drop (idx + 1) segments
in case afterDp of
(asinId : _) -> validateAsin asinId
[] -> Nothing
-- | Find the index of "dp" in the path
findDpIndex :: [Text] -> Maybe Int
findDpIndex segments =
findIndex (\s -> T.toLower s == "dp") segments
-- | Extract from /gp/product/{ASIN}
extractFromGpProduct :: [Text] -> Maybe Text
extractFromGpProduct segments =
case segments of
(gp : prod : asinId : _)
| T.toLower gp == "gp" && T.toLower prod == "product" ->
validateAsin asinId
_ -> Nothing
-- | Validate that a string looks like an ASIN
--
-- ASINs are 10 characters, alphanumeric (usually start with B0 for products)
validateAsin :: Text -> Maybe Text
validateAsin candidate =
let cleaned = T.takeWhile isAsinChar candidate
in if T.length cleaned == 10 && T.all isAsinChar cleaned
then Just cleaned
else Nothing
-- | Check if a character is valid in an ASIN
isAsinChar :: Char -> Bool
isAsinChar c = (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
-- | Build the canonical Amazon URL
--
-- Preserves the regional TLD from the original URL.
buildCanonicalAmazon :: URI -> Text -> URI
buildCanonicalAmazon originalUri asinId =
let tld = getAmazonTld originalUri
canonicalUrl = "https://www.amazon." <> tld <> "/dp/" <> asinId
in case URI.mkURI canonicalUrl of
Just uri -> uri
Nothing -> originalUri -- Fallback to original if construction fails
-- | Get the TLD portion of an Amazon URL
--
-- e.g., "amazon.co.uk" -> "co.uk", "amazon.com" -> "com"
getAmazonTld :: URI -> Text
getAmazonTld uri =
case getHost uri of
Nothing -> "com" -- Default fallback
Just host ->
let baseHost = stripSubdomain (T.toLower host)
in case T.stripPrefix "amazon." baseHost of
Just tld -> tld
Nothing -> "com" -- Default fallback