o2o-site-AEO/backend/services/indexnow.py
Mina Choi 6784e59ca5 최초 커밋 — 기존 코드 전체 + 문서 체계 신설
git 저장소가 없어 히스토리·협업 기반이 아예 없던 상태를 연다.
함께 문서를 재편했다. 그동안 문서가 있어도 "이 제품이 뭘 푸는가"와
"어떻게 도는가"를 담은 문서가 없어서, 목표 문장이 backend/frontend
README 두 곳에 복붙돼 있었다 — 상위 문서가 없어 아래로 샌 것이다.

신설
  README.md               레포 진입점 + 문서 지도 + 문서 규칙 4가지
  AGENTS.md               에이전트·신규 합류자용 함정 목록과 규약
                          (CLAUDE.md 는 여기로 걸린 심볼릭 링크)
  docs/PRODUCT.md         제품 정의 — 문제·사용자·원칙·**non-goals**·성공 기준
  docs/ARCHITECTURE.md    payload 경계·발행 파이프라인·서빙 결정·앱 분리 설계

이동
  backend/docs/DECISIONS.md → docs/DECISIONS.md
    백엔드만의 결정이 아니다. 게다가 코드 주석 ~25곳이 이미
    `docs/DECISIONS.md` 로 적고 있어 레포 루트 기준으로는 그게 맞다.

갱신
  docs/DEPLOY.md          서빙 결정 반영 — nginx 정적 서빙이 지금 경로(3절),
                          Azure 는 나중에 켤 때(4절)로 분리
  docs/ARCHITECTURE.md    사이트 = 한 장(2026-08-31) 구조 반영
  docs/COLLECTION_SEO_AEO_FLOW.md
                          robots.txt·sitemap.xml 은 오리진 루트에만 굽는다는 점 명시
  frontend/site/scripts/prerender.ts
                          헤더 주석의 렌더 보고서 경로가 실제(422줄)와 달라 수정

.gitignore
  ★ CLAUDE.md 를 더 이상 무시하지 않는다. 에이전트 지침은 팀과 모든
    에이전트가 공유하는 규약이라 커밋해야 한다 — 무시하면 클론한 사람이
    "배포 후 republish_all.py 필수" 같은 함정을 전달받지 못한다.
    개인용 오버라이드는 ~/.claude/CLAUDE.md 에 둔다.
2026-08-31 13:57:59 +09:00

112 lines
4.5 KiB
Python

"""발행본 URL 을 IndexNow 로 알린다 — 크롤러가 찾아올 때까지 기다리지 않는다.
★ 어디에 닿고 어디에 안 닿는지가 이 파일의 존재 이유다.
닿는다 네이버(2023-07 부터 지원) · Bing · Yandex · Seznam.
네이버가 소상공인 검색 트래픽의 주력이라 여기가 핵심이고,
Bing 은 ChatGPT 검색의 상류라 AEO 로도 값이 있다.
안 닿는다 **구글**. 구글은 IndexNow 를 지원하지 않는다(2021 년부터 테스트만 하고 채택 안 함).
구글 색인 요청 API(Indexing API)도 JobPosting·BroadcastEvent 전용이라 우리는 못 쓴다 —
URL 을 받아 200 을 주지만 그 밖의 타입은 그냥 버린다.
구글 쪽은 Search Console 사이트맵 제출이 유일한 자동화 경로다.
★ 보낼 URL 을 여기서 다시 계산하지 않는다. 사이트의 `sitemap.xml` 을 읽는다 —
프리렌더가 실제로 구운 페이지 목록이 거기 있다. 라우트 규칙을 두 군데 두면
사이트맵에 없는 URL 을 통보하게 되고, 그건 404 통보라 신뢰만 깎는다.
★ 실패해도 발행을 되돌리지 않는다. 색인 통보는 발행의 **부수 효과**다.
여기서 예외를 올리면 정적 파일이 이미 올라간 뒤에 발행이 실패로 뒤집힌다.
"""
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/out/sites"))
def site_urls(slug: str) -> list[str]:
"""이 사이트가 실제로 발행한 URL 목록(사이트맵의 `<loc>`)."""
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 요청 본문. 호스트는 URL 에서 뽑는다(커스텀 도메인도 그대로 맞는다).
한 요청의 URL 은 전부 같은 호스트여야 한다(규격). 섞여 있으면 422 를 받으므로
첫 URL 의 호스트에 속한 것만 보낸다."""
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 가 정상이다. 그 밖은 규격상 원인이 정해져 있다:
# 400 형식 · 403 키 불일치 · 422 호스트 불일치 · 429 과다 요청
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"])}