여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다. - 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>
151 lines
5.5 KiB
Python
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
|