o2o-site-AEO/solution/backend/services/search_console_service.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

130 lines
5.2 KiB
Python

"""발행 감지 → 사이트맵 제출 → 색인 조회 → 알림. 발행 잡과 별도 트랜잭션이다."""
import asyncio
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta, timezone
import httpx
from common.database.db_session_manager import DB_SESSION_MNG
from common.enums import DBType
from common.logger import LOG
from crud import search_console_crud as store
from services import site_payload
from services.search_console_alerts import alert_reason, send_alert
from services.search_console_client import SearchConsoleClient, SearchConsoleError
from services.search_console_settings import load_settings, belongs_to_property
async def run_scheduled_check():
try:
settings = load_settings()
if settings is None:
return
await DB_SESSION_MNG.execute_lambda_write(
DBType.MAIN.value, lambda session: run_batch(session, settings),
)
except Exception:
# 크론 실패가 API/발행에 전파되지 않고 다음 주기에 재시도된다.
LOG.w("[search-console] BATCH_FAILED — 설정 및 DB 마이그레이션 확인 필요")
async def run_batch(session, settings):
if not await store.lock_batch(session):
return
if not belongs_to_property(site_payload.publish_origin() + "/sitemap.xml", settings.property_url):
raise SearchConsoleError("PROPERTY_MISMATCH")
await register_publications(session, settings)
rows = await store.due_sites(session, settings.property_url)
if not rows:
return
async with SearchConsoleClient(settings.credentials_file) as client:
submitted = set()
for row in rows:
await check_site(client, row, settings, submitted)
await session.flush()
async def register_publications(session, settings):
for site, place, version in await store.new_publications(session, settings.property_url):
slug = site_payload.publish_slug(place, site)
page_url = f"{site_payload.publish_origin()}/s/{slug}"
if not belongs_to_property(page_url, settings.property_url):
LOG.w("[search-console] PROPERTY_MISMATCH")
continue
await store.register(session, {
"site_id": site.site_id, "site_version_id": version.site_version_id,
"property_url": settings.property_url, "page_url": page_url,
"published_at": site.published_at,
})
async def read_sitemap(sitemap_url: str) -> set[str]:
try:
async with httpx.AsyncClient(timeout=20, follow_redirects=False) as client:
async with client.stream("GET", sitemap_url) as response:
response.raise_for_status()
body = bytearray()
async for chunk in response.aiter_bytes():
body.extend(chunk)
if len(body) > 5_000_000:
raise SearchConsoleError("SITEMAP_TOO_LARGE")
root = ET.fromstring(body)
return {(item.text or "").strip() for item in root.iter(
"{http://www.sitemaps.org/schemas/sitemap/0.9}loc"
)}
except (httpx.HTTPError, ET.ParseError):
raise SearchConsoleError("SITEMAP_UNAVAILABLE") from None
async def submit_sitemap(client, row, submitted: set):
if row.sitemap_submitted_at:
return
sitemap_url = site_payload.publish_origin() + "/sitemap.xml"
# 디스크가 아니라 실제 공개 URL을 확인한다. 업로드 지연 시 Google에 먼저 알리지 않는다.
urls = await read_sitemap(sitemap_url)
if row.page_url not in urls:
raise SearchConsoleError("URL_NOT_IN_SITEMAP")
if sitemap_url not in submitted:
await client.submit_sitemap(row.property_url, sitemap_url)
submitted.add(sitemap_url)
row.sitemap_submitted_at = datetime.now(timezone.utc)
def record_inspection(row, inspection: dict, now):
row.inspection = inspection
row.inspected_at = now
row.error_code = None
row.failures = 0
if inspection.get("verdict") == "PASS":
row.first_indexed_at = row.first_indexed_at or now
row.next_check_at = now + timedelta(days=1)
def record_error(row, code: str, now):
row.error_code = code
row.failures = (row.failures or 0) + 1
hours = min(24, 2 ** min(row.failures - 1, 5))
row.next_check_at = now + timedelta(hours=hours)
async def check_site(client, row, settings, submitted: set):
now = datetime.now(timezone.utc)
try:
if not belongs_to_property(row.page_url, settings.property_url):
raise SearchConsoleError("PROPERTY_MISMATCH")
async with asyncio.timeout(90):
await submit_sitemap(client, row, submitted)
inspection = await client.inspect_url(row.property_url, row.page_url)
record_inspection(row, inspection, now)
except SearchConsoleError as ex:
record_error(row, ex.code, now)
except TimeoutError:
record_error(row, "CHECK_TIMEOUT", now)
except Exception:
record_error(row, "CHECK_FAILED", now)
reason = alert_reason(row, now, settings.alert_days)
if reason:
LOG.w(f"[search-console] site={row.site_id} {reason}")
if await send_alert(settings.alert_url, row.page_url, reason):
row.alerted_at = now