o2o-site-AEO/solution/backend/services/search_console_client.py
Mina Choi 11d30bb3d1 [chore] solution,admin,ontology: 코드 주석을 한 줄로 — 히스토리 주석 삭제
여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다.

- ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제
- 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다
- eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다

파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이).
site·frontend·admin tsc, site vitest 105 passed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:05:19 +09:00

151 lines
5.5 KiB
Python

"""Google Search Console REST 클라이언트 — 사이트맵 제출 · URL 색인 상태 조회."""
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:
"""동기 토큰 갱신."""
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