o2o-site-AEO/solution/backend/services/indexnow.py
Mina Choi 9d25ed613e 구조: 사장님(solution)과 내부 운영(admin)을 두 앱으로 가른다
최상단을 프로젝트 단위로 평평하게 둔다 — o2o-negosium 과 같은 규약이고, 이 레포만
다르게 갈 이유가 없다. negodata/{backend,front} 가 프로젝트 안에서 f/b 를 가르는 선례,
lps-admin/ 이 백엔드 없이 프론트만 가진 최상단 폴더의 선례다.

  backend/ frontend/{admin,site,shared}  →  solution/{backend,front,site,shared} + admin/

## 왜

내부 라우트(/local-content, /places/:id/seo)의 이름과 화면 코드가 사장님 번들에
그대로 실려 나가고 있었다. UserRole.DEVELOPER 주석의 "고객사에 존재를 노출하지 않는다"를
번들이 깨고 있었다 — 라우트 가드는 화면을 가리지 번들은 못 가린다.
번들을 갈라 확인했다: 사장님 dist 에서 local-content · /places · SeoAudit 이 전부 0건이다.

그 과정에서 두 곳이 더 새고 있었다.
- AppShell 의 NAV 배열이 내부 메뉴를 하드코딩하고 있었다. 앱을 가른 뒤에도 dist 에
  local-content 가 남아서 찾았다. 메뉴는 이제 앱이 prop 으로 들고 온다.
- EditorHeader·BuilderPage·LoginPage 가 /places 로 링크하고 있었다. 그 화면이 admin 으로
  나갔으니 사장님 앱에서는 404 다. 링크를 걷어내고 LoginPage 기본 도착지는 '/' 로 바꿨다
  (앱마다 홈이 다르고 각 라우터의 '/' 가 이미 그걸 안다).

## admin 에 백엔드를 두지 않았다

내부 화면이 부르는 훅이 전부 router/v1/{place,fact,local,validator} 에 이미 있다.
자체 백엔드를 두면 place·fact·link 를 같은 DB 에 대고 두 번 구현하게 된다.
대가는 solution/backend 가 죽으면 admin 도 멈추는 것 — 내부 도구라 감수한다.

## admin 의 `@` 는 solution/front/src 를 가리킨다

내부 화면이 쓰는 API 클라이언트·UI·수집 배선이 solution 에 한 벌만 있고 그 파일들끼리도
`@/...` 로 서로를 부른다. admin 에서 `@` 를 자기 src 로 잡으면 그 참조가 전부 깨진다
(실측 TS2307 14건). 복제하는 길도 있지만 RecollectPanel 주석이 금지한다 —
"수집 경로를 두 벌 만들면 확정 게이트"가 갈라진다.
admin 자기 파일만 `@admin` 이고, 의존 방향은 admin → solution 한 쪽뿐이다.

admin 이 여는 빌더는 다른 오리진이라 절대 URL + 새 탭이다(admin/src/lib/solutionUrl.ts).
react-router Link 로 두면 admin 안에서 라우트를 찾다 404 다.

## 그 밖

- npm 워크스페이스 루트를 레포 루트로 올렸다(admin 이 solution 밖이라).
- docker-compose 를 255→174줄로 줄이고 admin(:3002) 서비스를 넣었다. ADMIN_BIND 기본값은
  127.0.0.1 — 0.0.0.0 으로 열면 앱을 가른 의미가 없다.
- 발행 호스트를 프론트 .env 에 따로 적지 않는다. compose 가 루트의 SITE_PUBLIC_HOST 를
  VITE_PUBLISH_HOST 로 흘려보낸다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
- nginx/site.conf 를 git 에서 빼고 .example 만 남겼다(.env·*.toml 과 같은 규약).
  compose 가 bind mount 하므로 클론 직후 복사해야 한다 — 없으면 Docker 가 그 자리에
  디렉토리를 만들어 nginx 가 설정 없이 뜬다.
- config.test.toml.example 을 추가했다. 없으면 클론한 사람이 pytest 를 아예 못 돌린다
  (conftest import 단계에서 죽는다). 외부 API 키는 전부 빈값이다 —
  APP_ENV=test 가 .env 를 안 읽는 이유를 여기서 우회하면 안 된다.
- 경로가 한 칸 깊어져 test_schema_ddl(parents[2]→[3]) 과 test_site_theme 을 고쳤다.

검증: front·admin·site 전부 lint 0 / build 0. 백엔드 514 passed.
남은 4건(test_build_publish 3 · test_snapshot 1)은 이 변경 전부터 실패하던 것으로,
손대지 않은 메인 체크아웃에서 같은 4건이 같게 실패하는 것을 확인했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
2026-08-31 15:12:09 +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"])}