Bash script to check redirects

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"
  }

  # 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."
    return 1
  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
}

urlhops