사이트 발행 성공과 Google 색인 관측은 별도 상태다. 외부 API 장애로 발행이 실패하거나 재시작 때 추적 정보가 사라지지 않도록 분리. - Google 클라이언트·배치·DB·Teams 알림 모듈 분리 - 기존 스케줄러 연결, 재시도·중복 실행 방지와 선택 설정 추가 - ORM·초기 DDL·마이그레이션·운영 설정 문서 동시 갱신 검증: 관련 59건 통과, compose 설정·diff 검사 통과. 추가 회귀 23건 통과, 기존 발행 검수 실패 1건은 변경 전 코드에서도 재현. 운영 배포·Google/Teams 실호출 미실행.
165 lines
6.3 KiB
Python
165 lines
6.3 KiB
Python
"""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
|