"""Google Search Console REST 클라이언트 — 사이트맵 제출 · URL 색인 상태 조회. PUT https://www.googleapis.com/webmasters/v3/sites/{siteUrl}/sitemaps/{feedpath} POST https://searchconsole.googleapis.com/v1/urlInspection/index:inspect 자세한 계약은 docs/SEARCH_CONSOLE_CLIENT.md. ★ `SearchConsoleError` 는 `code` 문자열만 담는다 — Google 응답 본문·토큰·키·원본 예외 메시지는 절대 싣지 않는다. ★ `inspectionResult`·`indexStatusResult` 가 없거나 빈 응답은 "미색인"으로 넘겨짚지 않고 예외로 끊는다 — 검사 실패와 색인 결과를 구분 못하면 오판이 된다. """ from __future__ import annotations import asyncio from typing import Any from urllib.parse import quote import httpx SITEMAPS_BASE = "https://www.googleapis.com/webmasters/v3/sites" INSPECT_URL = "https://searchconsole.googleapis.com/v1/urlInspection/index:inspect" SCOPES = ["https://www.googleapis.com/auth/webmasters"] TIMEOUT_SEC = 20.0 AUTH_REFRESH_TIMEOUT_SEC = 20.0 class SearchConsoleError(RuntimeError): """code 만 담는다 — Google 응답 본문·토큰·키는 여기 실으면 안 된다.""" def __init__(self, code: str): self.code = code super().__init__(code) def _service_account_credentials(credentials_file: str): """lazy import — 미설치여도 이 모듈의 import 자체는 죽지 않는다.""" from google.oauth2 import service_account return service_account.Credentials.from_service_account_file(credentials_file, scopes=SCOPES) def _refresh_sync(credentials) -> None: """동기 토큰 갱신. `asyncio.to_thread` 로 감싸 부른다. `Request.__call__` 기본 타임아웃(120s)을 그대로 두면 만료된 키·막힌 네트워크에서 오래 걸릴 수 있다 — 매 요청에 상한을 강제로 덮어씌운다(호출측이 넘긴 값 포함).""" import requests from google.auth.transport.requests import Request with requests.Session() as session: original_request = session.request def _request_with_timeout(*args, **kwargs): kwargs["timeout"] = AUTH_REFRESH_TIMEOUT_SEC return original_request(*args, **kwargs) session.request = _request_with_timeout credentials.refresh(Request(session=session)) def _status_error_code(status_code: int) -> str: if status_code == 401: return "unauthorized" if status_code == 403: return "forbidden" if status_code == 429: return "rate_limited" if 500 <= status_code < 600: return "server_error" return f"http_{status_code}" def _transport_error_code(ex: httpx.HTTPError) -> str: if isinstance(ex, httpx.TimeoutException): return "timeout" return "transport_error" class SearchConsoleClient: def __init__(self, credentials_file: str, *, transport: httpx.AsyncBaseTransport | None = None): self._credentials_file = credentials_file self._client = httpx.AsyncClient(timeout=TIMEOUT_SEC, transport=transport) self._credentials: Any = None self._auth_lock = asyncio.Lock() async def __aenter__(self) -> "SearchConsoleClient": return self async def __aexit__(self, exc_type, exc, tb) -> None: await self.aclose() async def aclose(self) -> None: await self._client.aclose() async def submit_sitemap(self, property_url: str, sitemap_url: str) -> None: url = f"{SITEMAPS_BASE}/{quote(property_url, safe='')}/sitemaps/{quote(sitemap_url, safe='')}" headers = await self._auth_headers() res = await self._put(url, headers) self._raise_for_status(res) async def inspect_url(self, property_url: str, page_url: str) -> dict[str, Any]: headers = await self._auth_headers() body = {"inspectionUrl": page_url, "siteUrl": property_url} res = await self._post(INSPECT_URL, headers, body) self._raise_for_status(res) return _index_status_result(res) async def _put(self, url: str, headers: dict[str, str]) -> httpx.Response: try: return await self._client.put(url, headers=headers) except httpx.HTTPError as ex: raise SearchConsoleError(_transport_error_code(ex)) from None async def _post(self, url: str, headers: dict[str, str], body: dict[str, Any]) -> httpx.Response: try: return await self._client.post(url, headers=headers, json=body) except httpx.HTTPError as ex: raise SearchConsoleError(_transport_error_code(ex)) from None def _raise_for_status(self, res: httpx.Response) -> None: if not res.is_success: raise SearchConsoleError(_status_error_code(res.status_code)) async def _auth_headers(self) -> dict[str, str]: token = await self._access_token() return {"Authorization": f"Bearer {token}"} async def _access_token(self) -> str: credentials = self._load_credentials() if not getattr(credentials, "valid", False): async with self._auth_lock: if not getattr(credentials, "valid", False): try: await asyncio.to_thread(_refresh_sync, credentials) except Exception: raise SearchConsoleError("auth_failed") from None token = getattr(credentials, "token", None) if not token: raise SearchConsoleError("auth_failed") return token def _load_credentials(self) -> Any: if self._credentials is None: try: self._credentials = _service_account_credentials(self._credentials_file) except Exception: raise SearchConsoleError("invalid_credentials_file") from None return self._credentials def _index_status_result(res: httpx.Response) -> dict[str, Any]: try: data = res.json() except ValueError: raise SearchConsoleError("invalid_json") from None inspection_result = data.get("inspectionResult") if isinstance(data, dict) else None if not isinstance(inspection_result, dict): raise SearchConsoleError("missing_inspection_result") index_status_result = inspection_result.get("indexStatusResult") if not isinstance(index_status_result, dict) or not index_status_result: raise SearchConsoleError("missing_index_status_result") return index_status_result