packages feed

link-canonical-0.1.0.0: src/Link/Canonical/Rules/Twitter.hs

-- | Twitter/X URL normalization rule
--
-- == Canonical Form
--
-- @
-- https://x.com/{user}/status/{id}
-- @
--
-- == Handled Variants
--
-- * @twitter.com@ → @x.com@
-- * @www.twitter.com@ → @x.com@
-- * @mobile.twitter.com@ → @x.com@
-- * @x.com/{user}/status/{id}/photo/1@ → @x.com/{user}/status/{id}@
-- * @x.com/{user}/status/{id}/video/1@ → @x.com/{user}/status/{id}@
--
-- == Stripped Parameters
--
-- @s@, @t@, @ref_src@
module Link.Canonical.Rules.Twitter
  ( twitterRule,
    isTwitterUrl,
    twitterStrippedParams,
  )
where

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

-- | Twitter/X domain rule
twitterRule :: DomainRule
twitterRule = domainRule "twitter" isTwitterUrl normalizeTwitter

-- | Parameters to strip from Twitter URLs
twitterStrippedParams :: [Text]
twitterStrippedParams =
  [ "s", -- share source
    "t", -- timestamp/token
    "ref_src" -- referral source
  ]

-- | Twitter/X hostnames we recognize
twitterHosts :: [Text]
twitterHosts =
  [ "twitter.com",
    "www.twitter.com",
    "mobile.twitter.com",
    "x.com",
    "www.x.com"
  ]

-- | Check if a URI is a Twitter/X URL
isTwitterUrl :: URI -> Bool
isTwitterUrl uri =
  case getHost uri of
    Nothing -> False
    Just host -> T.toLower host `elem` twitterHosts

-- | Normalize a Twitter/X URL to canonical form
normalizeTwitter :: URI -> URI
normalizeTwitter uri =
  let -- Step 1: Normalize host to x.com
      withHost = normalizeHost uri
      -- Step 2: Strip media suffixes from status URLs
      withPath = stripMediaSuffix withHost
      -- Step 3: Strip Twitter-specific tracking parameters
      withQuery = stripTwitterParams withPath
   in withQuery

-- | Strip Twitter-specific tracking parameters
stripTwitterParams :: URI -> URI
stripTwitterParams uri =
  let params = URI.uriQuery uri
      filtered = filter (not . isTwitterTrackingParam) params
   in uri {URI.uriQuery = filtered}

-- | Check if a query parameter is a Twitter tracking parameter
isTwitterTrackingParam :: URI.QueryParam -> Bool
isTwitterTrackingParam (URI.QueryFlag k) =
  URI.unRText k `elem` twitterStrippedParams
isTwitterTrackingParam (URI.QueryParam k _) =
  URI.unRText k `elem` twitterStrippedParams

-- | Normalize host to x.com
normalizeHost :: URI -> URI
normalizeHost uri =
  case URI.uriAuthority uri of
    Left _ -> uri
    Right auth ->
      case URI.mkHost "x.com" of
        Nothing -> uri
        Just newHost ->
          let newAuth = auth {URI.authHost = newHost}
           in uri {URI.uriAuthority = Right newAuth}

-- | Strip /photo/N or /video/N suffixes from status URLs
--
-- Converts:
--   /{user}/status/{id}/photo/1 → /{user}/status/{id}
--   /{user}/status/{id}/video/1 → /{user}/status/{id}
stripMediaSuffix :: URI -> URI
stripMediaSuffix uri =
  case URI.uriPath uri of
    Nothing -> uri
    Just (trailing, segments) ->
      let segList = NE.toList segments
          newSegList = stripMediaFromPath segList
       in case newSegList of
            [] -> uri {URI.uriPath = Nothing}
            (x : xs) -> uri {URI.uriPath = Just (trailing, x NE.:| xs)}

-- | Strip photo/video segments from path
--
-- If path ends with /photo/N or /video/N, remove those segments
stripMediaFromPath :: [URI.RText 'URI.PathPiece] -> [URI.RText 'URI.PathPiece]
stripMediaFromPath segments =
  let segTexts = map URI.unRText segments
   in if isStatusWithMedia segTexts
        then take (length segments - 2) segments
        else segments

-- | Check if path is a status URL with media suffix
--
-- Pattern: /{user}/status/{id}/photo|video/{N}
isStatusWithMedia :: [Text] -> Bool
isStatusWithMedia segs =
  case segs of
    (_ : status : _ : mediaType : _ : _)
      | T.toLower status == "status"
          && (T.toLower mediaType == "photo" || T.toLower mediaType == "video") ->
          True
    _ -> False