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