사이트 발행 성공과 Google 색인 관측은 별도 상태다. 외부 API 장애로 발행이 실패하거나 재시작 때 추적 정보가 사라지지 않도록 분리. - Google 클라이언트·배치·DB·Teams 알림 모듈 분리 - 기존 스케줄러 연결, 재시도·중복 실행 방지와 선택 설정 추가 - ORM·초기 DDL·마이그레이션·운영 설정 문서 동시 갱신 검증: 관련 59건 통과, compose 설정·diff 검사 통과. 추가 회귀 23건 통과, 기존 발행 검수 실패 1건은 변경 전 코드에서도 재현. 운영 배포·Google/Teams 실호출 미실행.
130 lines
5.2 KiB
Python
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
|