"""네이버가 우리 사이트를 어떻게 보는지 **밖에서** 확인한다. ★ 파일명이 `probe.py` 가 아닌 이유: 공개 함수가 `probe()` 라서 패키지에서 이름이 겹친다. `from geo.naver import probe` 가 모듈이 아니라 함수를 주게 되고, `geo.naver.probe` 로 모듈 속성에 닿으려던 코드가 조용히 함수를 집는다(실제로 한 번 걸렸다). ★ 이 파일은 **DB 를 보지 않는다.** 세션을 인자로도 받지 않는다. "우리 DB 가 그렇다고 한다" 와 "밖에서 실제로 그렇게 보인다" 를 한 함수에 섞으면, 둘이 어긋났을 때 어느 쪽이 틀렸는지 말할 수 없다 — **그 어긋남을 찾는 것이 이 모듈의 존재 이유**다. 저장·판정 이력은 같은 패키지의 다른 파일이 맡는다(../README.md). ★ 화면에 찍지 않는다. `Finding` 목록만 돌려준다 — 라우터·워커 잡·CLI 가 같은 결과를 쓰게 하려는 것이다. 사람이 읽는 출력은 `geo/scripts/check_naver_eo.py` 가 만든다. ★ 공식 API 만 쓴다. 네이버 검색창을 긁지 않는다 — 봇 탐지 우회는 결론과 무관하게 영구 금지다(docs/DECISIONS.md 1-1). ★ 왜 네이버만 따로 보나 — 소상공인 검색 트래픽의 주력이면서, 우리가 가진 자동 경로가 IndexNow 하나뿐이다. 구글은 Search Console, Bing 은 웹마스터도구가 상태를 보여 주지만 네이버는 **서치어드바이저에 등록되기 전까지 아무 창도 없다.** 창이 없는 동안 잘못돼도 알 방법이 없어서, 지금 당장 확인할 수 있는 것만 본다. """ import os import re from dataclasses import asdict, dataclass, field import httpx from geo.naver._http import TIMEOUT_SEC, YETI_UA, get as _get, host as _host from geo.naver.web_search import NaverWebSearchUnavailable, search_web from geo.naver.web_search import enabled as web_search_enabled from services.external.naver import NaverLocalClient, NaverNotConfigured, NaverRequestFailed # 소유확인 토큰 = 서치어드바이저가 준 **파일명에서 `.html` 을 뺀 값**(예: naver1234abcd). # ★ 이 값은 `nginx/site.conf` 에도 적힌다 — **두 곳이다.** nginx 가 env 를 못 읽고, 그 파일은 # `.example` 만 커밋되기 때문이다. 여기 값은 "우리가 등록한 것", nginx 값은 "실제로 나가는 것" # 이고 **어긋나면 이 점검이 잡는다.** 그게 두 곳을 감수하는 근거다(geo/README.md '제약'). # ★ pydantic 설정이 아니라 env 를 직접 읽는다 — site_payload.SITE_HOST_ENV·indexnow.key() 와 # 같은 계열의 값이고, 발행 계열 env 는 그 관행이다. VERIFICATION_ENV = "NAVER_SITE_VERIFICATION" OK, WARN, FAIL, SKIP = "ok", "warn", "fail", "skip" @dataclass(frozen=True) class Finding: """점검 한 건. `fix` 는 **사람이 다음에 할 일**이다 — 없으면 할 일이 없다는 뜻이다.""" id: str label: str status: str detail: str fix: str | None = None @dataclass class NaverEoReport: origin: str findings: list[Finding] = field(default_factory=list) @property def failed(self) -> list[Finding]: return [f for f in self.findings if f.status == FAIL] @property def warned(self) -> list[Finding]: return [f for f in self.findings if f.status == WARN] @property def ok(self) -> bool: """실패가 없으면 True. **주의(WARN)는 실패로 세지 않는다** — 미확정이 섞여 있다 (네이버 색인은 등록 전에는 확정할 방법이 없다).""" return not self.failed def as_dict(self) -> dict: return { "origin": self.origin, "ok": self.ok, "findings": [asdict(f) for f in self.findings], } def verification_token() -> str: return os.environ.get(VERIFICATION_ENV, "").strip() # ── 소유확인 ───────────────────────────────────────────────────────────── async def check_verification(client: httpx.AsyncClient, origin: str, token: str | None = None) -> Finding: """소유확인 파일이 오리진 루트에서 열리는가 — `https:///.html`. ★ **메타태그가 아니라 파일 방식이다.** 메타태그는 랜딩 `` 에 들어가야 하는데 그건 `solution/frontend` 가 만든다 — `solution/` 을 고치지 않기로 했다(2026-09-11). 그래서 nginx 가 직접 내준다(`nginx/site.conf` 의 `location = /.html`). 덤으로 얻은 것: **프론트를 다시 구울 필요가 없다.** 값이 번들에 안 들어간다. ★★ **상태코드로 판단하면 안 된다.** nginx 맨 아래 `location /` 가 SPA 폴백을 주므로, 블록이 없거나 파일명이 한 글자 틀리면 **404 가 아니라 빌더 앱 HTML 이 200 으로** 나간다. 검색엔진은 "확인 실패" 만 뱉고 이유를 안 알려주는데, 눈으로는 파일이 있는 것처럼 보인다. → 그래서 **내용**으로 본다. `docs/DEPLOY.md` 가 Bing 파일에 대해 하는 경고와 같은 것이다. ★ 통과했다고 끝이 아니다. 검색엔진은 소유확인을 주기적으로 재확인하고, 내용이 사라진 시점에 등록이 풀리면서 **아무 알림도 오지 않는다.** """ token = verification_token() if token is None else token.strip() if not token: return Finding( "verification", "소유확인 파일", WARN, f"{VERIFICATION_ENV} 가 비었다 — 서치어드바이저 등록 전이다", "docs/DEPLOY.md 2-2단계", ) path = f"/{token}.html" res = await _get(client, origin + path) if res is None: return Finding("verification", "소유확인 파일", FAIL, f"{path} 에 연결하지 못했다") if res.status_code != 200: return Finding( "verification", "소유확인 파일", FAIL, f"{path} 이 HTTP {res.status_code}", "nginx/site.conf 에 location = " + path + " 블록이 있는지 확인해라", ) if token not in res.text: # 여기가 이 점검의 핵심이다 — 200 인데 내용이 다르면 거의 항상 SPA 폴백이다. shell = "id=\"root\"" in res.text or len(res.text) > 2000 why = "빌더 앱 HTML 이 200 으로 나온다" if shell else "내용에 토큰이 없다" return Finding( "verification", "소유확인 파일", FAIL, f"{path} 이 200 이지만 {why} ({len(res.text)}바이트)", "location 블록이 없거나 파일명이 다르다 — nginx/site.conf 를 확인하고 reload 해라", ) return Finding("verification", "소유확인 파일", OK, f"{path} — 토큰이 내용에 있다 ({len(res.text)}바이트)") async def check_yeti_landing(client: httpx.AsyncClient, origin: str) -> Finding: """랜딩을 **Yeti UA 로** 받아 본다. ★ `scripts/check_search_ready.py` 는 발행본(`/s/`)만 본다. 랜딩은 아무도 안 보는데, 소유확인이 여기 붙고 네이버가 사이트를 처음 여는 자리도 여기다. ★ 글자 수를 세는 이유: 랜딩은 프리렌더 대상이다(`react-router.config.ts` `prerender`). 그 설정이 빠지면 200 은 그대로인데 본문이 0자가 된다 — 실측(2026-09-07) 3,021바이트에 `` 0개·본문 0자였다. 상태코드로는 안 보이는 종류다. """ res = await _get(client, origin + "/", ua=YETI_UA) if res is None: return Finding("yeti_landing", "Yeti 로 랜딩", FAIL, "연결하지 못했다") if res.status_code != 200: return Finding( "yeti_landing", "Yeti 로 랜딩", FAIL, f"HTTP {res.status_code} — CDN·WAF 가 네이버 로봇을 막고 있다", "봇 차단 규칙에서 Yeti 를 빼라", ) words = _visible_chars(res.text) if words < 300: return Finding( "yeti_landing", "Yeti 로 랜딩", FAIL, f"본문 {words}자 — CSR 로 돌아갔다", "react-router.config.ts 의 prerender 목록을 확인해라", ) return Finding("yeti_landing", "Yeti 로 랜딩", OK, f"HTTP 200 · 본문 {words}자") # ── 색인 통보 경로 ─────────────────────────────────────────────────────── async def check_indexnow_path(client: httpx.AsyncClient, origin: str, slug: str | None = None) -> list[Finding]: """백엔드가 통보 URL 을 뽑는 파일이 **그 자리에 있는가.** ★ `services/indexnow.py site_urls()` 는 `/s//sitemap.xml` 을 읽어 `` 을 모은다. 없으면 빈 목록을 돌려주고 경고 한 줄만 남긴 뒤 **발행 잡은 성공한다.** 네이버로 가는 자동 경로가 이것뿐이라, 끊기면 통째로 끊긴다. ★ 로컬 `out/` 대신 HTTP 로 본다. 프리렌더가 쓰는 볼륨과 nginx 가 읽는 볼륨이 같아서, 밖에서 404 면 백엔드가 읽을 파일도 없다. 그리고 로컬 경로를 아는 것은 백엔드 몫이라 여기서 다시 조립하면 규칙이 두 군데가 된다. """ out: list[Finding] = [] res = await _get(client, origin + "/sitemap.xml") if res is None or res.status_code != 200: code = res.status_code if res else "연결실패" out.append(Finding( "root_sitemap", "루트 사이트맵", FAIL, f"HTTP {code} — 통보할 URL 목록의 출처가 없다", )) return out locs = re.findall(r"\s*([^<\s]+)\s*", res.text) site_locs = [loc for loc in locs if "/s/" in loc] out.append(Finding( "root_sitemap", "루트 사이트맵", OK, f"URL {len(locs)}개 (발행 사이트 {len(site_locs)}개)", )) if not slug: found = re.search(r"/s/([^/?#]+)", site_locs[0]) if site_locs else None slug = found.group(1) if found else None if not slug: out.append(Finding("indexnow_urls", "IndexNow 통보 URL", SKIP, "확인할 사이트가 없다")) return out res = await _get(client, f"{origin}/s/{slug}/sitemap.xml") if res is None: out.append(Finding("indexnow_urls", "IndexNow 통보 URL", FAIL, "연결하지 못했다")) elif res.status_code == 200 and "" in res.text: n = len(re.findall(r"", res.text)) out.append(Finding("indexnow_urls", "IndexNow 통보 URL", OK, f"/s/{slug}/sitemap.xml — URL {n}개")) else: out.append(Finding( "indexnow_urls", "IndexNow 통보 URL", FAIL, f"/s/{slug}/sitemap.xml 이 HTTP {res.status_code} — indexnow.site_urls() 가 빈 목록을 " "돌려준다. 네이버·Bing 통보가 조용히 0건이다", "indexnow.site_urls() 가 루트 사이트맵을 읽게 고쳐라", )) return out # ── 네이버에서 보이는가 ────────────────────────────────────────────────── async def check_web_index(origin: str, place_name: str) -> Finding: """웹문서 검색에 우리 URL 이 잡히는가. ★ 없을 때 FAIL 이 아니라 WARN 이다 — 이 API 의 결과는 통합검색 색인과 같지 않아서 "안 잡혔다" 로 "색인 안 됐다" 를 단정할 수 없다(web_search.py 머리주석). 확정 판정을 보려면 서치어드바이저 등록이 필요하고, 그게 소유확인이 전제인 이유다. ★ 지역검색(`check_place_backlink`)은 백엔드 호출기를 그대로 쓰는데 여기만 geo 안의 호출기를 쓴다 — 백엔드를 고치지 않기로 했기 때문이다. 쿼터가 갈리는 대가는 `web_search.py` 머리주석에 적어 뒀다. """ if not web_search_enabled(): return Finding("web_index", "네이버 웹문서 색인", SKIP, "NAVER_CLIENT_ID / SECRET 가 없다") try: items = await search_web(place_name) except NaverWebSearchUnavailable as ex: return Finding("web_index", "네이버 웹문서 색인", FAIL, str(ex)) host = _host(origin) hits = [i for i in items if host in (i.get("link") or "")] if hits: return Finding("web_index", "네이버 웹문서 색인", OK, f'"{place_name}" 검색 {len(items)}건 중 우리 사이트 {len(hits)}건') return Finding( "web_index", "네이버 웹문서 색인", WARN, f'"{place_name}" 검색 {len(items)}건에 우리 사이트가 없다 — 미색인이거나 이 API 범위 밖이다', "서치어드바이저에 등록해 색인 진단을 봐라", ) async def check_place_backlink(origin: str, place_name: str, client: NaverLocalClient | None = None) -> Finding: """네이버 플레이스가 **우리 사이트를 가게 홈페이지로 가리키는가.** ★ 왜 중요한가 — 우리는 `sameAs` 로 네이버를 가리키지만 그건 우리→네이버 한 방향이다. 네이버가 이 홈페이지를 그 업소의 공식 사이트로 **인정하는** 신호는 반대 방향, 스마트플레이스의 "홈페이지" 칸에 우리 주소가 들어가는 것이다. 사장님이 직접 넣어야 하고, 비용이 0 이면서 가장 강한 신호다. ★ 보는 필드는 `NaverPlace.place_url` 이다. 이 값은 응답의 `link` 인데 **네이버 플레이스 페이지가 아니라 업체 자체 홈페이지**다(external/naver.py 실측 주석) — 그래서 역방향 연결을 여기서 볼 수 있다. 이름이 `link` 가 아니라는 것에 주의한다. ★ 후보는 최대 5건이고 전화번호가 안 와서 동명 업소를 가릴 근거가 약하다. 그래서 "일치/불일치" 로 단정하지 않고 **후보의 link 를 그대로 담는다** — 판단은 사람이 한다. """ api = client or NaverLocalClient() if not api.enabled: return Finding("place_backlink", "스마트플레이스 역방향 링크", SKIP, "NAVER_CLIENT_ID / SECRET 가 없다") try: candidates = await api.search_local(place_name) except (NaverNotConfigured, NaverRequestFailed) as ex: return Finding("place_backlink", "스마트플레이스 역방향 링크", FAIL, f"{type(ex).__name__}: {ex}") if not candidates: return Finding( "place_backlink", "스마트플레이스 역방향 링크", WARN, f'"{place_name}" 로 네이버 지역검색 결과가 없다', ) host = _host(origin) for cand in candidates: # name 은 from_item 에서 이미 태그를 벗긴 값이다 — 다시 벗기지 않는다. if host in (cand.place_url or ""): return Finding( "place_backlink", "스마트플레이스 역방향 링크", OK, f"{cand.name} → {cand.place_url}", ) seen = " · ".join(f"{c.name}={c.place_url or '(없음)'}" for c in candidates) return Finding( "place_backlink", "스마트플레이스 역방향 링크", WARN, f"우리 주소가 없다 — 후보: {seen}", "스마트플레이스 '홈페이지' 칸에 발행본 주소를 넣도록 사장님께 안내해라", ) # ── 한 번에 ────────────────────────────────────────────────────────────── async def probe( origin: str, *, place_name: str | None = None, slug: str | None = None, token: str | None = None, naver: NaverLocalClient | None = None, ) -> NaverEoReport: """전체 점검. 라우터·워커 잡·CLI 가 **같이 부르는 자리**다. `place_name` 이 없으면 네이버 검색이 필요한 두 항목을 건너뛴다 — 상호명 없이는 질의를 만들 수 없고, 호스트명으로 던지면 결과가 의미를 갖지 않는다.""" report = NaverEoReport(origin=origin.rstrip("/")) async with httpx.AsyncClient(timeout=TIMEOUT_SEC, follow_redirects=True) as client: report.findings.append(await check_verification(client, report.origin, token)) report.findings.append(await check_yeti_landing(client, report.origin)) report.findings.extend(await check_indexnow_path(client, report.origin, slug)) if place_name: # 웹문서검색은 geo 안의 호출기, 지역검색은 백엔드 호출기다(check_web_index 주석). report.findings.append(await check_web_index(report.origin, place_name)) api = naver or NaverLocalClient() try: report.findings.append(await check_place_backlink(report.origin, place_name, api)) finally: if naver is None: await api.aclose() else: report.findings.append( Finding("naver_search", "색인·역방향 링크", SKIP, "상호명을 주면 확인한다") ) return report # ── 내부 ───────────────────────────────────────────────────────────────── def _visible_chars(html: str) -> int: """JS 실행 없이 읽히는 글자 수. check_search_ready.py 와 같은 셈이다.""" body = re.sub(r"<(script|style)[^>]*>.*?", " ", html, flags=re.S) return len(re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", body)).strip())