o2o-site-AEO/solution/backend/services/search_console_client.py
Mina Choi 3f47d5ecd2 [feat] solution/backend: 서치콘솔 자동 제출·색인 상태 추적 추가
사이트 발행 성공과 Google 색인 관측은 별도 상태다. 외부 API 장애로 발행이 실패하거나 재시작 때 추적 정보가 사라지지 않도록 분리.

- Google 클라이언트·배치·DB·Teams 알림 모듈 분리
- 기존 스케줄러 연결, 재시도·중복 실행 방지와 선택 설정 추가
- ORM·초기 DDL·마이그레이션·운영 설정 문서 동시 갱신

검증: 관련 59건 통과, compose 설정·diff 검사 통과. 추가 회귀 23건 통과, 기존 발행 검수 실패 1건은 변경 전 코드에서도 재현. 운영 배포·Google/Teams 실호출 미실행.
2026-09-15 14:50:30 +09:00

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