#!/usr/bin/env sh # link-check.sh — 連結可達性檢查(連結寫進任何文件之前先跑這一支)。 # 用法: # link-check.sh <網址>... # printf '%s\n' <網址>... | link-check.sh # 沒給參數就從標準輸入一行一個讀 # # 輸出: 每個網址一行,三欄以 TAB 分隔 # {OK|DEAD|SKIP}{網址}{說明} # OK = 連得到 # DEAD = 連不到,呼叫端不得把它寫進文件 # SKIP = 沒有可查的端點(mailto:、錨點、相對路徑),不影響結束碼 # # 驗證方式依網址種類分流: # Gitea wiki 頁(///wiki/<頁名>) -> API /repos///wiki/page/<頁名> # Gitea 議題(…/issues/<編號>) -> API /repos///issues/<編號> # 其他 Gitea 網址 -> HTTP HEAD,帶金鑰 # 非 Gitea 的外部網址 -> HTTP HEAD,不帶金鑰 # # 規則: # - Gitea 一律走 API,不看網頁狀態碼。私有存取庫的網頁網址對未登入請求一律回 404, # 用網頁狀態碼判斷會把好連結判成壞連結,接著整批砍掉還在的頁。 # - 外部網址不帶金鑰。金鑰是這個站台的憑證,送去別的主機就是憑證外洩。 # - 這支只回報,不改任何檔案。呼叫端拿到結束碼 0 才可以把連結寫進文件。 # # 環境變數: GITEA_HOST(例 https://gitea.jsc.idv.tw)、GITEA_TOKEN # GITEA_TOKEN 未設定時退回 tea CLI 的登入金鑰(~/.config/tea/config.yml)。 # 優先取 url 與 GITEA_HOST 同主機的登入:tea 可以同時登入多個站台,只看 default # 會把 A 站的金鑰送去 B 站,那是憑證外洩,不是單純取錯值。 # # 結束碼: 0=全部連得到 1=至少一筆連不到 2=用法錯誤(一個網址都沒給) # 3=清單裡有 Gitea 網址,但 GITEA_HOST 未設定 7=Gitea 認證失敗(HTTP 401、403) # 7 一定要與 1 分開:金鑰失效時,Gitea 對私有存取庫的回應與「頁不存在」難以分辨。 # 兩者混用,一次金鑰過期就把整批還在的頁判成死連結,接著這些頁會被當成壞連結刪掉或改寫。 set -eu TAB=$(printf '\t') emit() { # <狀態> <網址> <說明> printf '%s%s%s%s%s\n' "$1" "$TAB" "$2" "$TAB" "$3" } host_of() { # <網址> -> 只留主機名,轉小寫 printf '%s' "$1" | sed 's#^[a-zA-Z]*://##; s#/.*##; s#^.*@##; s#:[0-9]*$##' | tr 'A-Z' 'a-z' } path_of() { # <網址> -> 去掉協定、主機、查詢字串與錨點之後的路徑,頭尾不留斜線 printf '%s' "$1" | sed 's#^[a-zA-Z]*://[^/]*##; s/[?#].*$//; s#^/##; s#/$##' } seg() { # <路徑> <序號> -> 第 n 段 printf '%s' "$1" | cut -d/ -f"$2" } nseg() { # <路徑> -> 段數 printf '%s' "$1" | awk -F/ '{print NF}' } looks_like_gitea_path() { # <路徑> -> 形狀像 wiki 頁或議題就回 0 _p="$1" [ "$(nseg "$_p")" -ge 4 ] || return 1 case "$(seg "$_p" 3)" in wiki|issues) return 0 ;; *) return 1 ;; esac } resolve_token() { # 印出這次要用的金鑰;一把都沒有就印空字串 if [ -n "${GITEA_TOKEN:-}" ]; then printf '%s' "$GITEA_TOKEN"; return 0; fi cfg="${HOME:-}/.config/tea/config.yml" [ -f "$cfg" ] || return 0 awk -v want="$GITEA_HOST_ONLY" ' function flush() { if (tok == "") return if (first == "") first = tok if (want != "" && host == want) match_tok = tok if (def) def_tok = tok } /^ *- / { flush(); tok=""; host=""; def=0 } /^ *token: */ { line=$0; sub(/^ *token: */,"",line); gsub(/"/,"",line); tok=line } /^ *url: */ { line=$0; sub(/^ *url: */,"",line); gsub(/"/,"",line) sub(/^https?:\/\//,"",line); sub(/\/.*$/,"",line); host=line } /^ *default: *true/ { def=1 } END { flush() if (match_tok != "") { printf "%s", match_tok; exit } if (def_tok != "") printf "%s", def_tok else printf "%s", first } ' "$cfg" } http_status() { # <網址> [金鑰] -> 印出 HTTP 狀態碼,連不上印 000 _m="$1"; _u="$2"; _t="${3:-}" # HEAD 走 -I,不走 -X HEAD:後者讓 curl 等一個永遠不會來的內文,整支腳本卡在那裡。 # GET 退讓只要第一個位元組(-r 0-0),拿的是狀態碼,不必把整份內容抓回來。 if [ "$_m" = HEAD ]; then set -- -I; else set -- -r 0-0; fi # 連線與總時間分兩個上限。主機根本不通時,5 秒就收工;主機活著但慢,才讓它用滿 20 秒。 # 只設總時間的話,一頁十條連結碰上不通的主機要等三分多鐘,呼叫端會以為整支腳本掛了。 if [ -n "$_t" ]; then _c=$(curl -sS -o /dev/null -L --connect-timeout 5 --max-time 20 -w '%{http_code}' "$@" \ -H "Authorization: token $_t" "$_u" 2>/dev/null || true) else _c=$(curl -sS -o /dev/null -L --connect-timeout 5 --max-time 20 -w '%{http_code}' "$@" "$_u" 2>/dev/null || true) fi case "$_c" in ''|*[!0-9]*) _c=000 ;; esac printf '%s' "$_c" } api_status() { # -> 印出 HTTP 狀態碼,連不上印 000 _c=$(curl -sS -o /dev/null --connect-timeout 5 --max-time 20 -w '%{http_code}' \ -H "Authorization: token $TOKEN" "$API$1" 2>/dev/null || true) case "$_c" in ''|*[!0-9]*) _c=000 ;; esac printf '%s' "$_c" } auth_stop() { # <網址> -> 印出這一筆,回報金鑰問題並中止 emit SKIP "$1" "Gitea 認證失敗(HTTP $2),無法判定" echo "[jsc][連結檢查][ERR]:Gitea 金鑰失效或權限不足(HTTP $2)。請換一支有效的 GITEA_TOKEN,或重新 tea login 之後再跑一次;在那之前不要把任何連結判成死連結。" >&2 exit 7 } # ---- 收集網址 ---- list=$(mktemp) raw=$(mktemp) trap 'rm -f "$list" "$raw"' EXIT if [ "$#" -gt 0 ]; then printf '%s\n' "$@" > "$raw" else cat > "$raw" fi # 先去掉行尾空白,再丟掉空白行:清單多半是別的指令產出的,尾巴常帶一行空的, # 那一行會被當成一個網址,最後回一個沒人看得懂的 DEAD。 sed 's/[[:space:]]*$//' "$raw" | grep -v '^[[:space:]]*$' > "$list" || true if [ ! -s "$list" ]; then echo 'usage: link-check.sh <網址>... ;或用標準輸入一行一個網址' >&2 exit 2 fi # ---- Gitea 站台設定 ---- GITEA_HOST_ONLY=$(host_of "${GITEA_HOST:-}") if [ -z "$GITEA_HOST_ONLY" ]; then # GITEA_HOST 沒設定就認不出哪些是 Gitea 網址,只能看路徑形狀。認出一筆就整批停下來: # 少了主機設定,Gitea 連結只驗得到網頁狀態碼,私有存取庫會全部被判成死連結。 while IFS= read -r u; do if looks_like_gitea_path "$(path_of "$u")"; then echo "[jsc][連結檢查][ERR]:清單裡有 Gitea 網址($u),但 GITEA_HOST 未設定。請先設定 GITEA_HOST 再跑一次,不得跳過驗證。" >&2 exit 3 fi done < "$list" fi case "${GITEA_HOST:-}" in http://*|https://*) HOST="${GITEA_HOST:-}" ;; '') HOST='' ;; *) HOST="https://${GITEA_HOST}" ;; esac API='' [ -z "$HOST" ] || API="${HOST%/}/api/v1" TOKEN=$(resolve_token) # ---- 逐筆檢查 ---- dead=0 while IFS= read -r url; do case "$url" in http://*|https://*) ;; *) emit SKIP "$url" "不是 http 或 https 網址,沒有可查的端點" continue ;; esac uhost=$(host_of "$url") upath=$(path_of "$url") if [ -n "$GITEA_HOST_ONLY" ] && [ "$uhost" = "$GITEA_HOST_ONLY" ]; then owner=$(seg "$upath" 1); repo=$(seg "$upath" 2); kind=$(seg "$upath" 3) rest='' [ "$(nseg "$upath")" -lt 4 ] || rest=$(printf '%s' "$upath" | cut -d/ -f4-) # 編輯畫面的網址尾巴指的還是同一頁,去掉再查。 rest=$(printf '%s' "$rest" | sed 's#/_edit$##; s#/_new$##; s#/_pages$##') if [ "$kind" = wiki ] && [ -n "$rest" ]; then code=$(api_status "/repos/$owner/$repo/wiki/page/$rest") case "$code" in 2*) emit OK "$url" "wiki 頁存在(API HTTP $code)" ;; 401|403) auth_stop "$url" "$code" ;; 404) emit DEAD "$url" "wiki 頁不存在(API HTTP 404)"; dead=1 ;; *) emit DEAD "$url" "wiki API 回 HTTP $code"; dead=1 ;; esac continue fi if [ "$kind" = issues ] && [ -n "$rest" ]; then idx=$(printf '%s' "$rest" | cut -d/ -f1) case "$idx" in ''|*[!0-9]*) emit DEAD "$url" "議題編號不是數字:$idx"; dead=1; continue ;; esac code=$(api_status "/repos/$owner/$repo/issues/$idx") case "$code" in 2*) emit OK "$url" "議題存在(API HTTP $code)" ;; 401|403) auth_stop "$url" "$code" ;; 404) emit DEAD "$url" "議題不存在(API HTTP 404)"; dead=1 ;; *) emit DEAD "$url" "議題 API 回 HTTP $code"; dead=1 ;; esac continue fi code=$(http_status HEAD "$url" "$TOKEN") case "$code" in 2*|3*) emit OK "$url" "Gitea 網址可達(HEAD HTTP $code)" ;; 401|403) auth_stop "$url" "$code" ;; 000) emit DEAD "$url" "連不上 Gitea 主機(沒有回應)"; dead=1 ;; *) emit DEAD "$url" "Gitea 網址回 HTTP $code"; dead=1 ;; esac continue fi # 外部網址:不帶金鑰。HEAD 被擋掉時再用 GET 試一次,有些站台只擋 HEAD。 code=$(http_status HEAD "$url") case "$code" in 2*|3*) ;; *) code=$(http_status GET "$url") ;; esac case "$code" in 2*|3*) emit OK "$url" "外部網址可達(HTTP $code)" ;; 000) emit DEAD "$url" "連不上主機(沒有回應)"; dead=1 ;; *) emit DEAD "$url" "外部網址回 HTTP $code"; dead=1 ;; esac done < "$list" exit "$dead"