"""Read-only health check for the published web interface. This script never uploads a document, calls a model endpoint, or reads dotenv secrets. It only verifies that the public HTML shell is reachable. """ from __future__ import annotations import argparse from collections.abc import Callable import json from time import perf_counter, sleep from typing import Any from urllib.parse import urlsplit, urlunsplit from urllib.request import Request, urlopen DEFAULT_EXPECTED_TEXT = "文件預檢所" LOCAL_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"}) def validate_target_url(url: str, *, allow_http_localhost: bool = False) -> str: """Validate and normalize a secret-free health-check target.""" candidate = url.strip() parsed = urlsplit(candidate) if parsed.username or parsed.password: raise ValueError("健康檢查網址不可包含帳號或密碼。") if parsed.query or parsed.fragment: raise ValueError("健康檢查網址不可包含 query 或 fragment。") if not parsed.hostname: raise ValueError("健康檢查網址缺少主機名稱。") is_local_http = ( parsed.scheme == "http" and allow_http_localhost and parsed.hostname.lower() in LOCAL_HOSTS ) if parsed.scheme != "https" and not is_local_http: raise ValueError("遠端健康檢查只接受 HTTPS;HTTP 僅限明確允許的 localhost。") path = parsed.path or "/" return urlunsplit((parsed.scheme, parsed.netloc, path, "", "")) def check_once( url: str, *, expected_text: str = DEFAULT_EXPECTED_TEXT, timeout_seconds: float = 20, max_response_bytes: int = 2 * 1024 * 1024, opener: Callable[..., Any] = urlopen, ) -> dict[str, object]: """Fetch one HTML shell and return non-sensitive health metadata.""" if timeout_seconds <= 0: raise ValueError("timeout_seconds 必須大於 0。") if max_response_bytes <= 0: raise ValueError("max_response_bytes 必須大於 0。") if not expected_text: raise ValueError("expected_text 不可為空。") request = Request( url, headers={ "Accept": "text/html", "Accept-Encoding": "identity", "User-Agent": "doc-inspector-release-check/1.0", }, method="GET", ) started = perf_counter() with opener(request, timeout=timeout_seconds) as response: status = int(response.getcode()) content_type = response.headers.get_content_type() charset = response.headers.get_content_charset() or "utf-8" payload = response.read(max_response_bytes + 1) final_url = response.geturl() elapsed_ms = max(0, round((perf_counter() - started) * 1000)) if status != 200: raise RuntimeError(f"介面回傳 HTTP {status}。") if content_type != "text/html": raise RuntimeError(f"介面回傳的 Content-Type 不是 text/html:{content_type}。") if len(payload) > max_response_bytes: raise RuntimeError("介面 HTML 超過健康檢查讀取上限。") try: html = payload.decode(charset) except (LookupError, UnicodeDecodeError) as exc: raise RuntimeError("介面 HTML 無法依宣告編碼解碼。") from exc if expected_text not in html: raise RuntimeError("介面可連線,但找不到預期的應用程式標題。") return { "healthy": True, "http_status": status, "content_type": content_type, "expected_text_found": True, "response_bytes": len(payload), "elapsed_ms": elapsed_ms, "final_url": final_url, "uploads_document": False, "calls_model": False, "reads_env_truth": False, } def run_health_check( url: str, *, expected_text: str = DEFAULT_EXPECTED_TEXT, retries: int = 6, delay_seconds: float = 10, timeout_seconds: float = 20, allow_http_localhost: bool = False, opener: Callable[..., Any] = urlopen, sleep_fn: Callable[[float], None] = sleep, ) -> dict[str, object]: """Retry a sleeping deployment and return a stable JSON-ready report.""" if retries < 1: raise ValueError("retries 必須至少為 1。") if delay_seconds < 0: raise ValueError("delay_seconds 不可小於 0。") target = validate_target_url( url, allow_http_localhost=allow_http_localhost, ) failures: list[dict[str, object]] = [] for attempt in range(1, retries + 1): try: result = check_once( target, expected_text=expected_text, timeout_seconds=timeout_seconds, opener=opener, ) return { **result, "target_url": target, "attempts": attempt, "previous_failures": failures, } except Exception as exc: # Network libraries expose several exception types. failures.append( { "attempt": attempt, "error_type": type(exc).__name__, "message": str(exc), } ) if attempt < retries: sleep_fn(delay_seconds) return { "healthy": False, "target_url": target, "attempts": retries, "failures": failures, "uploads_document": False, "calls_model": False, "reads_env_truth": False, } def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="檢查公開介面 HTML 是否可用;不會上傳文件或呼叫模型。", ) parser.add_argument("--url", required=True, help="公開 HTTPS URL") parser.add_argument( "--expected-text", default=DEFAULT_EXPECTED_TEXT, help="HTML 中必須出現的文字", ) parser.add_argument("--retries", type=int, default=6) parser.add_argument("--delay-seconds", type=float, default=10) parser.add_argument("--timeout-seconds", type=float, default=20) parser.add_argument( "--allow-http-localhost", action="store_true", help="只供本機容器預檢使用", ) return parser def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) try: report = run_health_check( args.url, expected_text=args.expected_text, retries=args.retries, delay_seconds=args.delay_seconds, timeout_seconds=args.timeout_seconds, allow_http_localhost=args.allow_http_localhost, ) except ValueError as exc: report = { "healthy": False, "configuration_error": str(exc), "uploads_document": False, "calls_model": False, "reads_env_truth": False, } print(json.dumps(report, ensure_ascii=False)) return 0 if report["healthy"] else 1 if __name__ == "__main__": raise SystemExit(main())