Bash/Powershell script to check redirects

function urlhops {
  $MaxHops    = 30
  $TimeoutSec = 20
  $TitleBytes = 250000

  $SecureTls = ($env:URLHOPS_SECURE -eq "1")
  $NoColor   = -not [string]::IsNullOrEmpty($env:NO_COLOR)

  # Colors (best-effort)
  $Esc = [char]27
  $UseColor = -not $NoColor
  $RST=""; $BLD=""; $FG_LGT=""; $BG_GRN=""; $BG_RED=""; $BG_BLU=""; $BG_MAG=""; $BG_GRY=""
  if ($UseColor) {
    $RST    = "$Esc[0m"; $BLD="$Esc[1m"; $FG_LGT="$Esc[97m"
    $BG_GRN="$Esc[42m"; $BG_RED="$Esc[41m"; $BG_BLU="$Esc[44m"; $BG_MAG="$Esc[45m"; $BG_GRY="$Esc[100m"
  }

  function Strip-Ansi([string]$s) { if ($null -eq $s) { "" } else { [regex]::Replace($s, "`e\[[0-9;]*[mK]", "") } }
  function VisLen([string]$s) { (Strip-Ansi $s).Length }

  function Badge([string]$code) {
    $bg = $BG_GRY
    if ($code -match '^\d{3}$') {
      switch -regex ($code) {
        '^2\d\d$' { $bg = $BG_GRN; break }
        '^3\d\d$' { $bg = $BG_BLU; break }
        '^4\d\d$' { $bg = $BG_RED; break }
        '^5\d\d$' { $bg = $BG_MAG; break }
        default   { $bg = $BG_GRY; break }
      }
    }
    if (-not $UseColor) { return "[ $code ]" }
    return ("{0}{1}{2}{0}[ {3,3} ]{4}" -f $BLD, $bg, $FG_LGT, $code, $RST)
  }

  function Normalize-Url([string]$u) {
    if ($null -eq $u) { $u = "" }
    $u = $u.Trim()
    if ($u -match '^(?i)https?://') { return $u }
    if ([string]::IsNullOrEmpty($u)) { return $u }
    "http://$u"
  }

  function Abs-Url([string]$base, [string]$loc) {
    if ([string]::IsNullOrEmpty($loc)) { return "" }
    if ($loc -match '^(?i)https?://') { return $loc }
    try {
      $baseUri = New-Object System.Uri($base)
      (New-Object System.Uri($baseUri, $loc)).AbsoluteUri
    } catch { $loc }
  }

  function Get-CurlExe {
    $cmd = Get-Command curl.exe -ErrorAction SilentlyContinue
    if (-not $cmd) { throw "No encuentro curl.exe en PATH." }
    $cmd.Source
  }
  $CurlExe = Get-CurlExe

  function Get-StatusAndLocation([string]$url) {
    # Captura headers incluso si curl los manda por stderr
    $args = @(
      "-sS",
      "--max-time", "$TimeoutSec",
      "--connect-timeout", "$TimeoutSec",
      "-H", "User-Agent: urlhops/visual-1.2",
      "-o", "NUL",
      "-D", "-",
      "-I",
      $url
    )
    if (-not $SecureTls) { $args = @("-k") + $args }

    try {
      $raw = & $CurlExe @args 2>&1
      if (-not $raw) { return @{ Code="000"; Location="" } }

      # Normaliza a texto único y separa en líneas
      $text  = ($raw | Out-String)
      $lines = $text -split "`r?`n"

      # Encuentra el ÚLTIMO status HTTP del output (evita "200 Connection established", etc.)
      $statusIdx = @()
      for ($i=0; $i -lt $lines.Count; $i++) {
        if ($lines[$i] -match '^HTTP/\S+\s+(\d{3})') { $statusIdx += $i }
      }
      if ($statusIdx.Count -eq 0) { return @{ Code="000"; Location="" } }

      $last = $statusIdx[-1]
      $code = $matches[1]

      # Location: busca desde ese bloque hacia abajo hasta línea vacía
      $loc = ""
      for ($j=$last+1; $j -lt $lines.Count; $j++) {
        $ln = $lines[$j]
        if ([string]::IsNullOrWhiteSpace($ln)) { break }
        if ($ln -match '^(?i)Location:\s*(.+)\s*$') { $loc = $matches[1].Trim(); break }
      }

      return @{ Code=$code; Location=$loc }
    } catch {
      return @{ Code="000"; Location="" }
    }
  }

  function Fetch-Title([string]$url) {
    $args = @(
      "-sS", "-L", "--compressed",
      "--max-time", "$TimeoutSec",
      "--connect-timeout", "$TimeoutSec",
      "-H", "User-Agent: urlhops/title-1.2",
      $url
    )
    if (-not $SecureTls) { $args = @("-k") + $args }

    try {
      $raw = & $CurlExe @args 2>&1
      if (-not $raw) { return "" }
      $html = ($raw | Out-String)
      if ($html.Length -gt $TitleBytes) { $html = $html.Substring(0, $TitleBytes) }
      $one = $html -replace "`r"," " -replace "`n"," "
      $m = [regex]::Match($one, '(?is)<title[^>]*>\s*(.{0,500}?)\s*</title>')
      if ($m.Success) { return ([regex]::Replace($m.Groups[1].Value, '\s+', ' ').Trim()) }
      ""
    } catch { "" }
  }

  # ASCII box
  $TL='+'; $TR='+'; $BL='+'; $BR='+'; $H='-'; $V='|'
  function Box-Top([int]$w) { $TL + ($H * $w) + $TR }
  function Box-Bot([int]$w) { $BL + ($H * $w) + $BR }
  function Box-Row([int]$w, [string]$text) {
    $pad = $w - (VisLen $text); if ($pad -lt 0) { $pad = 0 }
    $V + " " + $text + (" " * $pad) + " " + $V
  }

  while ($true) {
    Write-Host ""
    Write-Host ""
    Write-Host "Paste URLs (space/tab/comma/newline). Finish with an EMPTY LINE:"

    $urls = New-Object System.Collections.Generic.List[string]
    while ($true) {
      $line = Read-Host
      if ([string]::IsNullOrEmpty($line)) { break }
      $line = $line -replace "`t"," " -replace ","," "
      $parts = $line.Split(" ", [System.StringSplitOptions]::RemoveEmptyEntries)
      foreach ($p in $parts) { if ($p) { [void]$urls.Add($p) } }
    }

    if ($urls.Count -eq 0) { Write-Host "No URLs detected."; continue }

    foreach ($u in $urls) {
      $start = Normalize-Url $u

      $r = Get-StatusAndLocation $start
      $code = $r.Code
      $loc  = $r.Location

      $lines = New-Object System.Collections.Generic.List[string]
      $lines.Add( ("{0} {1}{2}{3}" -f (Badge $code), $BLD, $start, $RST) )

      $cur = $start
      $hops = 0
      while ($hops -lt $MaxHops) {
        $hops++
        if ($code -match '^30(1|2|3|7|8)$' -and -not [string]::IsNullOrEmpty($loc)) {
          $lines.Add("  -> $loc")
          $next = Normalize-Url (Abs-Url $cur $loc)
          if ($next -eq $cur) { break }
          $cur = $next

          $r = Get-StatusAndLocation $cur
          $code = $r.Code
          $loc  = $r.Location
          $lines.Add( ("{0} {1}" -f (Badge $code), $cur) )
          continue
        }
        break
      }

      $title = Fetch-Title $cur
      if ([string]::IsNullOrEmpty($title)) { $title = "<empty>" }
      $lines.Add("Title: $title")

      $w = 0
      foreach ($s in $lines) { $w = [Math]::Max($w, (VisLen $s)) }
      $w += 2

      Write-Host (Box-Top $w)
      foreach ($s in $lines) { Write-Host (Box-Row $w $s) }
      Write-Host (Box-Bot $w)
      Write-Host ""
    }
  }
}

urlhops
urlhops() {
  # Minimal deps: bash, curl, awk, sed, tr, head
  # Input: paste URLs separated by space/tab/comma/newline, finish with EMPTY LINE.
  #
  # TLS behavior:
  # - Default: INSECURE (curl -k).
  # - Secure mode: URLHOPS_SECURE=1 urlhops  (no -k).

  local max_hops=30
  local timeout=20
  local title_bytes=250000

  # Default insecure; secure mode disables -k
  local CURL_TLS="-k"
  [ "${URLHOPS_SECURE:-0}" = "1" ] && CURL_TLS=""

  # Colors (disable if not TTY or NO_COLOR set)
  local use_color=1
  [[ ! -t 1 ]] && use_color=0
  [[ -n "${NO_COLOR:-}" ]] && use_color=0

  local RST='' BLD='' FG_LGT='' BG_GRN='' BG_RED='' BG_BLU='' BG_MAG='' BG_GRY=''
  if [ "$use_color" -eq 1 ]; then
    RST=$'\033[0m'; BLD=$'\033[1m'
    FG_LGT=$'\033[97m'
    BG_GRN=$'\033[42m'; BG_RED=$'\033[41m'; BG_BLU=$'\033[44m'; BG_MAG=$'\033[45m'; BG_GRY=$'\033[100m'
  fi

  # ASCII box drawing (Cygwin-safe)
  local TL='+' TR='+' BL='+' BR='+' H='-' V='|'

  _strip_ansi() { LC_ALL=C sed -r 's/\x1B\[[0-9;]*[mK]//g'; }
  _vislen() { printf '%s' "$1" | _strip_ansi | LC_ALL=C awk '{print length($0)}'; }

  _badge() {
    local code="$1" bg
    case "$code" in
      2??) bg="$BG_GRN" ;;
      3??) bg="$BG_BLU" ;;
      4??) bg="$BG_RED" ;;
      5??) bg="$BG_MAG" ;;
      *)   bg="$BG_GRY" ;;
    esac
    printf '%s%s%s%s[ %3s ]%s' "$BLD" "$bg" "$FG_LGT" "$BLD" "$code" "$RST"
  }

  _normalize_url() {
    local u="$1"
    if [[ "$u" =~ ^https?:// ]]; then printf '%s' "$u"
    else printf 'http://%s' "$u"
    fi
  }

  _abs_url() {
    # Best-effort Location resolution without python
    local base="$1" loc="$2"
    [ -z "$loc" ] && { printf '%s' ""; return 0; }
    [[ "$loc" =~ ^https?:// ]] && { printf '%s' "$loc"; return 0; }

    local origin path dir
    origin="$(printf '%s' "$base" | LC_ALL=C sed -nE 's#^(https?://[^/]+).*#\1#p')"
    [ -z "$origin" ] && { printf '%s' "$loc"; return 0; }

    if [[ "$loc" == /* ]]; then
      printf '%s%s' "$origin" "$loc"
      return 0
    fi

    path="$(printf '%s' "$base" | LC_ALL=C sed -nE 's#^https?://[^/]+(/.*)?#\1#p')"
    [ -z "$path" ] && path="/"
    dir="$(printf '%s' "$path" | LC_ALL=C sed -E 's#[^/]*$##')"
    [ -z "$dir" ] && dir="/"

    printf '%s%s%s' "$origin" "$dir" "$loc"
  }

  _status_and_location() {
    # Outputs: "<code>\n<location>\n"
    local url="$1" headers code location
    headers="$(
      curl $CURL_TLS -sS -D - -o /dev/null \
        --max-time "$timeout" \
        --connect-timeout "$timeout" \
        -H 'User-Agent: urlhops/visual-1.2' \
        "$url" 2>/dev/null
    )"
    if [ -z "$headers" ]; then
      printf '000\n\n'
      return 0
    fi

    code="$(printf '%s' "$headers" | LC_ALL=C awk 'NR==1 {print $2}')"
    [ -z "$code" ] && code="000"
    location="$(printf '%s' "$headers" | LC_ALL=C awk 'BEGIN{IGNORECASE=1} /^Location:[[:space:]]*/ {sub(/^Location:[[:space:]]*/,""); gsub("\r",""); print; exit}')"
    printf '%s\n%s\n' "$code" "$location"
  }

  _fetch_title() {
    # Safe title extraction; returns empty if not found.
    local url="$1" html title
    html="$(
      curl $CURL_TLS -sS -L --compressed \
        --max-time "$timeout" \
        --connect-timeout "$timeout" \
        -H 'User-Agent: urlhops/title-1.2' \
        "$url" 2>/dev/null | head -c "$title_bytes"
    )"
    [ -z "$html" ] && { printf '%s' ""; return 0; }

    # Extract first <title>...</title>, case-insensitive, collapse whitespace.
    title="$(printf '%s' "$html" \
      | tr '\n' ' ' \
      | LC_ALL=C sed -nE 's/.*<[Tt][Ii][Tt][Ll][Ee][^>]*>([^<]{0,500})<[/][Tt][Ii][Tt][Ll][Ee][^>]*>.*/\1/p' \
      | head -n 1 \
      | LC_ALL=C sed -E 's/[[:space:]]+/ /g; s/^[[:space:]]+//; s/[[:space:]]+$//')"

    printf '%s' "$title"
  }

  _box_top() { printf '%s%*s%s\n' "$TL" "$1" '' "$TR" | tr ' ' "$H"; }
  _box_bot() { printf '%s%*s%s\n' "$BL" "$1" '' "$BR" | tr ' ' "$H"; }
  _box_row() {
    local w="$1" text="$2"
    local pad=$((w - $(_vislen "$text"))); (( pad < 0 )) && pad=0
    printf '%s %s%*s %s\n' "$V" "$text" "$pad" "" "$V"
  }

  # BUCLE PRINCIPAL: repetir prompt -> procesar -> volver a preguntar
  while true; do
    # Visual separation before prompt
    echo
    echo
    echo "Paste URLs (space/tab/comma/newline). Finish with an EMPTY LINE:"

    # Read until blank line
    local urls=() line
    while IFS= read -r line; do
      [ -z "$line" ] && break
      line="${line//$'\t'/ }"
      line="${line//,/ }"
      # shellcheck disable=SC2206
      local parts=($line)
      local p
      for p in "${parts[@]}"; do
        [ -n "$p" ] && urls+=("$p")
      done
    done

    if [ ${#urls[@]} -eq 0 ]; then
      echo "No URLs detected."
      continue
    fi

    local u
    for u in "${urls[@]}"; do
      local start="$(_normalize_url "$u")"

      # First hop (initial URL, always bold)
      local out code loc
      out="$(_status_and_location "$start")"
      code="$(printf '%s' "$out" | LC_ALL=C sed -n '1p')"
      loc="$(printf '%s' "$out" | LC_ALL=C sed -n '2p')"

      local lines=()
      lines+=("$(_badge "$code") ${BLD}${start}${RST}")

      local cur="$start"
      local hops=0
      while [ $hops -lt $max_hops ]; do
        hops=$((hops+1))

        if [[ "$code" =~ ^30[12378]$ ]] && [ -n "$loc" ]; then
          lines+=("  -> $loc")
          local next
          next="$(_abs_url "$cur" "$loc")"
          next="$(_normalize_url "$next")"
          [ "$next" = "$cur" ] && break
          cur="$next"

          out="$(_status_and_location "$cur")"
          code="$(printf '%s' "$out" | LC_ALL=C sed -n '1p')"
          loc="$(printf '%s' "$out" | LC_ALL=C sed -n '2p')"
          lines+=("$(_badge "$code") $cur")
          continue
        fi
        break
      done

      local title="$(_fetch_title "$cur")"
      [ -z "$title" ] && title="<empty>"
      lines+=("Title: $title")

      local w=0 s len
      for s in "${lines[@]}"; do
        len="$(_vislen "$s")"
        (( len > w )) && w=$len
      done
      w=$((w + 2))

      _box_top "$w"
      for s in "${lines[@]}"; do _box_row "$w" "$s"; done
      _box_bot "$w"
      echo
    done
  done
}

urlhops