set -eu
VERSION="2025.11.04"
PROGRAM_NAME="$(basename "$0")"
readonly PROGRAM_NAME
print_version()
{
cat << _EOF_
${VERSION}
_EOF_
}
usage()
{
cat << _EOF_
${PROGRAM_NAME} -- a simple wrapper around curl to easily download files.
Usage: ${PROGRAM_NAME} <URL>...
${PROGRAM_NAME} [--curl-options <CURL_OPTIONS>]... [--no-decode-filename] [-o|-O|--output <PATH>] [--dry-run] [--] <URL>...
${PROGRAM_NAME} [--curl-options=<CURL_OPTIONS>]... [--no-decode-filename] [--output=<PATH>] [--dry-run] [--] <URL>...
${PROGRAM_NAME} -h|--help
${PROGRAM_NAME} -V|--version
Options:
--curl-options <CURL_OPTIONS>: Specify extra options to be passed when invoking curl. May be
specified more than once.
-o, -O, --output <PATH>: Use the provided output path instead of getting it from the URL. If
multiple URLs are provided, resulting files share the same name with a
number appended to the end (curl >= 7.83.0). If this option is provided
multiple times, only the last value is considered.
--no-decode-filename: Don't percent-decode the output filename, even if the percent-encoding in
the URL was done by wcurl, e.g.: The URL contained whitespace.
--dry-run: Don't actually execute curl, just print what would be invoked.
-V, --version: Print version information.
-h, --help: Print this usage message.
<CURL_OPTIONS>: Any option supported by curl can be set here. This is not used by wcurl; it is
instead forwarded to the curl invocation.
<URL>: URL to be downloaded. Anything that is not a parameter is considered
an URL. Whitespace is percent-encoded and the URL is passed to curl, which
then performs the parsing. May be specified more than once.
_EOF_
}
error()
{
printf "%s\n" "$*" > /dev/stderr
exit 1
}
CURL_OPTIONS=""
URLS=""
OUTPUT_PATH=""
HAS_USER_SET_OUTPUT="false"
readonly PER_URL_PARAMETERS="\
--fail \
--globoff \
--location \
--proto-default https \
--remote-time \
--retry 5 "
readonly UNSAFE_PERCENT_ENCODE="2F 5C"
DRY_RUN="false"
sanitize()
{
if [ -z "${URLS}" ]; then
error "You must provide at least one URL to download."
fi
readonly CURL_OPTIONS URLS DRY_RUN HAS_USER_SET_OUTPUT
}
is_subset_of()
{
case "${1}" in
*[!${2}]* | '') return 1 ;;
esac
}
is_safe_percent_encode()
{
upper_str=$(printf "%s" "${1}" | tr "[:lower:]" "[:upper:]")
for unsafe in ${UNSAFE_PERCENT_ENCODE}; do
if [ "${unsafe}" = "${upper_str}" ]; then
return 1
fi
done
return 0
}
percent_decode()
{
printf "%s\n" "${1}" | fold -w1 | while IFS= read -r decode_out; do
if [ "${decode_out}" = % ] && IFS= read -r decode_hex1; then
decode_out="${decode_out}${decode_hex1}"
if IFS= read -r decode_hex2; then
decode_out="${decode_out}${decode_hex2}"
if [ "${DECODE_FILENAME}" = "true" ] \
&& is_subset_of "${decode_hex1}" "23456789abcdefABCDEF" \
&& is_subset_of "${decode_hex2}" "0123456789abcdefABCDEF" \
&& is_safe_percent_encode "${decode_out}"; then
decode_out="$(printf "%b" "\\$(printf %o "0x${decode_hex1}${decode_hex2}")")"
fi
fi
fi
printf %s "${decode_out}"
done
}
get_url_filename()
{
hostname_and_path="$(printf %s "${1}" | sed -e 's,^[^/]*//,,' -e 's,?.*$,,')"
case "${hostname_and_path}" in
*/*) percent_decode "$(printf %s "${hostname_and_path}" | sed -e 's,^.*/,,')" ;;
esac
}
exec_curl()
{
CMD="curl "
curl_version=$($CMD --version | cut -f2 -d' ' | head -n1)
curl_version_major=$(echo "$curl_version" | cut -f1 -d.)
curl_version_minor=$(echo "$curl_version" | cut -f2 -d.)
CURL_NO_CLOBBER=""
CURL_PARALLEL=""
if [ "${curl_version_major}" -ge 8 ]; then
CURL_NO_CLOBBER="--no-clobber"
CURL_PARALLEL="--parallel"
if [ "${curl_version_minor}" -ge 16 ]; then
CURL_PARALLEL="--parallel --parallel-max-host 5"
fi
elif [ "${curl_version_major}" -eq 7 ]; then
if [ "${curl_version_minor}" -ge 83 ]; then
CURL_NO_CLOBBER="--no-clobber"
fi
if [ "${curl_version_minor}" -ge 66 ]; then
CURL_PARALLEL="--parallel"
fi
fi
set -- $URLS
if [ "$#" -lt 2 ]; then
CURL_PARALLEL=""
fi
set -- ${CMD} ${CURL_PARALLEL}
NEXT_PARAMETER=""
for url in ${URLS}; do
if [ "${HAS_USER_SET_OUTPUT}" = "false" ]; then
OUTPUT_PATH="$(get_url_filename "${url}")"
[ -z "${OUTPUT_PATH}" ] && OUTPUT_PATH=index.html
fi
set -- "$@" ${NEXT_PARAMETER} ${PER_URL_PARAMETERS} ${CURL_NO_CLOBBER} --output "${OUTPUT_PATH}" ${CURL_OPTIONS} "${url}"
NEXT_PARAMETER="--next"
done
if [ "${DRY_RUN}" = "false" ]; then
exec "$@"
else
printf "%s\n" "$@"
fi
}
DECODE_FILENAME="true"
while [ -n "${1-}" ]; do
case "${1}" in
--curl-options=*)
opt=$(printf "%s\n" "${1}" | sed 's/^--curl-options=//')
CURL_OPTIONS="${CURL_OPTIONS} ${opt}"
;;
--curl-options)
shift
CURL_OPTIONS="${CURL_OPTIONS} ${1}"
;;
--dry-run)
DRY_RUN="true"
;;
--output=*)
opt=$(printf "%s\n" "${1}" | sed 's/^--output=//')
HAS_USER_SET_OUTPUT="true"
OUTPUT_PATH="${opt}"
;;
-o | -O | --output)
shift
HAS_USER_SET_OUTPUT="true"
OUTPUT_PATH="${1}"
;;
-o* | -O*)
opt=$(printf "%s\n" "${1}" | sed 's/^-[oO]//')
HAS_USER_SET_OUTPUT="true"
OUTPUT_PATH="${opt}"
;;
--no-decode-filename)
DECODE_FILENAME="false"
;;
-h | --help)
usage
exit 0
;;
-V | --version)
print_version
exit 0
;;
--)
shift
for url in "$@"; do
newurl=$(printf "%s\n" "${url}" | sed 's/ /%20/g')
URLS="${URLS} ${newurl}"
done
break
;;
-*)
error "Unknown option: '$1'."
;;
*)
newurl=$(printf "%s\n" "${1}" | sed 's/ /%20/g')
URLS="${URLS} ${newurl}"
;;
esac
shift
done
sanitize
exec_curl