"""발행본 URL 을 IndexNow 로 알린다 — 크롤러가 찾아올 때까지 기다리지 않는다.""" import os import xml.etree.ElementTree as ET from pathlib import Path from urllib.parse import urlsplit import httpx from common.logger import LOG ENDPOINT = "https://api.indexnow.org/indexnow" SITEMAP_NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}" TIMEOUT_SEC = 10.0 # 규격 상한은 한 번에 10,000 개다. MAX_URLS = 10_000 def key() -> str: return os.environ.get("INDEXNOW_KEY", "").strip() def is_configured() -> bool: return bool(key()) def output_dir() -> Path: return Path(os.environ.get("SITE_OUTPUT_DIR", "/app/solution/site/out")) def site_urls(slug: str) -> list[str]: """이 사이트가 실제로 발행한 URL 목록(사이트맵의 ``).""" sitemap = output_dir() / "s" / slug / "sitemap.xml" if not sitemap.is_file(): return [] try: root = ET.parse(sitemap).getroot() except ET.ParseError as ex: LOG.w(f"[indexnow] 사이트맵을 읽지 못했다 — {sitemap}: {ex}") return [] urls = [(node.text or "").strip() for node in root.iter(f"{SITEMAP_NS}loc")] return [url for url in urls if url][:MAX_URLS] def _payload(urls: list[str]) -> dict | None: """IndexNow 요청 본문.""" host = urlsplit(urls[0]).netloc if not host: return None same_host = [url for url in urls if urlsplit(url).netloc == host] return { "host": host, "key": key(), # 키 파일은 오리진 루트에 있다(프리렌더가 굽고 azure_static 이 올린다). "keyLocation": f"https://{host}/{key()}.txt", "urlList": same_host, } async def submit(slug: str) -> dict | None: """설정된 경우에만 통보한다.""" if not is_configured(): return None urls = site_urls(slug) if not urls: LOG.w(f"[indexnow] 보낼 URL 이 없다 — 사이트맵이 없거나 비었다: {slug}") return None body = _payload(urls) if body is None: LOG.w(f"[indexnow] URL 에서 호스트를 못 읽었다: {urls[0]}") return None try: async with httpx.AsyncClient(timeout=TIMEOUT_SEC) as client: res = await client.post(ENDPOINT, json=body) except httpx.HTTPError as ex: LOG.w(f"[indexnow] 통보 실패 {slug}: {type(ex).__name__}: {ex}") return {"ok": False, "error": f"{type(ex).__name__}: {ex}", "urls": len(body['urlList'])} # 200 OK · 202 Accepted 가 정상이다. ok = res.status_code in (200, 202) if ok: LOG.i(f"[indexnow] {slug} — URL {len(body['urlList'])}개 통보 (HTTP {res.status_code})") else: LOG.w(f"[indexnow] {slug} 거절됨 HTTP {res.status_code}: {res.text[:200]}") return {"ok": ok, "status": res.status_code, "urls": len(body["urlList"])}