Files
gitea/tools/link-check.sh
jiantw83 9ec18b8a94 feat(link): 連結一律寫成 [文字](絕對網址),寫入前先驗證連得到
取消 [[頁名]] 與 [[顯示文字|頁名]] 兩種同 wiki 寫法,不再分「同存取庫」與
「跨存取庫」兩條規則。那種寫法只在自己那個 wiki 內解析,寫錯不報錯,畫面上
看起來像普通文字或死連結,巡不到也修不了。

連結寫進頁面前先過 jsc-gitea 的 link-check.sh,結束碼 0 才寫。驗證一律走 API,
不看網頁狀態碼:私有存取庫的網頁網址對未登入請求一律回 404,拿狀態碼判會把
好連結判成壞的。認證失敗回 7,與死連結的 1 分開,免得金鑰一過期就把還在的頁
整批判死。
2026-09-02 14:27:18 +08:00

232 lines
9.4 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env sh
# link-check.sh — 連結可達性檢查(連結寫進任何文件之前先跑這一支)。
# 用法:
# link-check.sh <網址>...
# printf '%s\n' <網址>... | link-check.sh # 沒給參數就從標準輸入一行一個讀
#
# 輸出: 每個網址一行,三欄以 TAB 分隔
# {OK|DEAD|SKIP}<TAB>{網址}<TAB>{說明}
# OK = 連得到
# DEAD = 連不到,呼叫端不得把它寫進文件
# SKIP = 沒有可查的端點(mailto:、錨點、相對路徑),不影響結束碼
#
# 驗證方式依網址種類分流:
# Gitea wiki 頁(<GITEA_HOST>/<owner>/<repo>/wiki/<頁名>) -> API /repos/<owner>/<repo>/wiki/page/<頁名>
# Gitea 議題(…/issues/<編號>) -> API /repos/<owner>/<repo>/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() { # <HEAD|GET> <網址> [金鑰] -> 印出 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() { # <API 路徑> -> 印出 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"