"""발행 감지 → 사이트맵 제출 → 색인 조회 → 알림. 발행 잡과 별도 트랜잭션이다.""" 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