o2o-site-AEO/solution/backend/services/site_slug.py
Mina Choi 079c93a62a [feat] solution,postgres-init,docs: 생성 진행 상태 · 새로 만들기 존중 · 발행본 색인·파비콘
작업트리에 커밋되지 않은 채 쌓여 있던 것과, 오늘 찾은 문제 셋을 함께 담는다.

## 1. 콘텐츠 생성 진행 상태 (작업트리에 있던 것)

COPY 잡의 실제 단계를 DB에 기록하고 응답으로 내보낸다. 폴링 횟수로 진행률을 흉내 내던
것을 걷어냈다. 새로고침·재접속해도 jobId 로 이어서 본다.

- services/copy_steps.py · services/job_progress.py · common/job_errors.py (신규)
- postgres-init/migrations/0013_job_progress.sql + init.sql
- 프론트: useGenerationJob · generationLabels (신규), Step5Generating·pollJob 배선,
  orval 모델 갱신(jobProgress · jobStep · jobStepStatus · jobStepReason)
- docs/GENERATION_FLOW.md (신규)

## 2. 발행된 사이트만 색인한다

실측(2026-09-15): 디스크의 발행본 33곳 중 **15곳이 draft 인데 `index, follow`** 였고
사이트맵에도 올라가 있었다. 사장님이 발행 버튼을 누른 적 없는 사이트가 짓다 만 상태로
구글에 실려 있었다는 뜻이다.

head.ts 가 robots 를 하드코딩하고 payload 의 `site.status` 를 보지 않았다.
"색인을 막을 이유가 없다"는 주석은 굽는 것이 곧 발행이던 시절의 말인데, 지금은 빌더
미리보기만 눌러도 draft 로 구워진다.

- seo/head.ts: PUBLISHED 일 때만 index, 아니면 `noindex, follow`
- 사이트맵·`/s` 목록·llms.txt 에서도 함께 빠진다 — 그쪽은 구운 HTML 의 robots 를 읽어
  거른다(seo/directory.ts readBakedNoindex). 규칙을 두 자리에 두지 않으려고 한 곳에 뒀다

## 3. [새로 크롤링하고 사이트 생성하기] 를 뒤집지 않는다

ba90a19 의 중복 합치기가 **일부러 다시 만들려는 경우까지** 기존 사업장으로 끌고 갔다 —
새로 만들기를 눌렀는데 기존 에디터가 열린다(사장님 보고 2026-09-15).

- Req_VerifyPlaceByUrl.reuse_existing (기본 True — 다른 호출자의 동작은 그대로)
- place_service.verify_place_by_url: 끄면 이어붙이지 않는다. 다만 **비어 있는 중복 행은
  계속 치운다** — 원래 막으려던 누적이 그것이고 빈 행은 잃을 것이 없다
- ensureServerPlace: 위저드는 새로 만들기 경로에서만 오므로 False 로 보낸다

## 4. 발행본 파비콘

발행본에 파비콘 링크가 아예 없어 브라우저 탭에 기본 아이콘이 떴다. 파일은 오리진 루트의
공용 자산이라 사이트마다 복사하지 않고 루트 절대경로로 가리킨다.

검증: site vitest 84건 통과 · tsc(site·frontend) · eslint 통과.
백엔드 pytest 는 로컬 DB 비밀번호가 맞지 않아 돌리지 못했다(a5b8701 과 같은 자리).
발행본 반영에는 전체 재굽기가 필요하다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 17:29:27 +09:00

95 lines
5.9 KiB
Python

"""사이트 주소(네임스페이스) 규칙 — 확인(check)과 저장(POST)이 함께 쓰는 단 하나의 정의.
★ 주소는 한 번 정하면 AI 검색이 색인하는 영구 식별자다. 그래서
- 규칙은 이 파일 한 곳에만 둔다. 확인과 저장이 서로 다른 규칙을 쓰면
"쓸 수 있다고 해놓고 저장에서 튕기는" 최악의 화면이 나온다.
- 클라이언트 검증은 믿지 않는다. 저장 직전에 서버가 이 규칙으로 다시 본다.
★ ASCII 소문자·숫자·하이픈만 받는다.
한글 상호를 그대로 주소로 쓰면 경로는 퍼센트 인코딩(/s/%EC%8A%A4%ED%85%8C%EC%9D%B4),
서브도메인은 퓨니코드(xn--…)가 된다 — 사장님이 전화로 불러줄 수도, 명함에 적을 수도 없는 주소다.
(프론트 `shared/src/lib/slug.ts` 의 publishUrl 이 ASCII 슬러그면 서브도메인으로 낸다.)
"""
import re
from typing import Optional
# 3~50자. 처음과 끝은 영문 소문자·숫자여야 하고, 하이픈은 가운데에만 올 수 있다.
# (1 + 1~48 + 1 = 3~50자)
SLUG_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9-]{1,48}[a-z0-9])$")
SLUG_MIN_LEN = 3
SLUG_MAX_LEN = 50
# 불가 사유 코드. 응답의 reason 으로 그대로 나가고, 프론트가 문구를 고른다.
REASON_LENGTH = "INVALID_LENGTH" # 3~50자를 벗어남
REASON_FORMAT = "INVALID_FORMAT" # 한글·대문자·언더스코어·연속 하이픈·양끝 하이픈
REASON_RESERVED = "RESERVED" # 서비스가 선점한 이름
REASON_TAKEN = "TAKEN" # 다른 사업장이 이미 쓰는 주소
REASON_LOCKED = "LOCKED" # ★ 이미 발행됨 — 색인된 주소는 바꾸지 않는다
# 예약어. 두 부류가 섞여 있다.
# 1) 서비스가 실제로 쓰는 경로·서브도메인(/s, /healthz, api. …) — 내주면 사이트가 서로를 가린다.
# 2) 관용적으로 시스템을 뜻하는 이름(admin, root, www …) — 사장님 사이트가 이 주소를 갖고 있으면
# 방문자도 크롤러도 공식 홈페이지로 읽지 않는다.
RESERVED_SLUGS = frozenset({
# 서비스 경로 / 인프라 서브도메인
"s", "site", "sites", "api", "app", "admin", "www", "www2", "static", "assets", "cdn", "media",
"img", "images", "css", "js", "files", "download", "downloads", "public", "dev", "stage", "staging",
"test", "demo", "local", "localhost", "mail", "smtp", "imap", "pop", "ftp", "ns", "ns1", "ns2", "mx",
"webmail", "vpn", "proxy", "gateway", "internal", "console", "dashboard", "manage", "management",
# 서버가 실제로 서비스하는 엔드포인트
"health", "healthz", "healthcheck", "status", "metrics", "robots", "sitemap", "llms", "favicon",
"manifest", "feed", "rss", "opensearch", "well-known", "v1", "v2", "graphql", "openapi", "docs", "swagger",
# 계정 / 정책 페이지
"login", "logout", "signin", "signup", "register", "join", "auth", "oauth", "account", "accounts",
"user", "users", "me", "my", "owner", "root", "system", "support", "help", "contact", "about",
"terms", "privacy", "policy", "legal", "security", "billing", "payment", "pay", "order", "orders",
# 도메인 리소스 이름 — 관리 API 경로와 같아 헷갈린다
"place", "places", "fact", "facts", "faq", "faqs", "job", "jobs", "report", "reports", "unit", "units",
"search", "index", "home", "main", "new", "edit", "delete", "create", "update",
# 값이 비었을 때 프론트가 문자열로 흘려보내는 것들 — 주소로 들어오면 버그의 흔적이다
"null", "undefined", "none", "nan", "true", "false",
# ★ 손으로 만든 목업이 쓰는 주소(solution/site/out/s, mockup/README).
# payload 가 없어 프리렌더가 굽지 않는 자리인데, 사장님이 이 주소로 발행하면
# **payload 가 생기는 순간** 덮여서 유일본이 영영 사라진다(AGENTS.md ★★ 항목).
# 실측(2026-09-15): `stay` 는 주인이 있어 우연히 TAKEN 이었을 뿐이고
# `stay2` · `stay3` 는 주인도 예약도 없어 "쓸 수 있다" 로 나갔다.
# ★ 굽기 쪽 보호(prerender PROTECTED_SLUGS)와 겹쳐 두는 것이지 대신하는 게 아니다 —
# 저쪽은 이미 나간 발행을 막고, 여기는 애초에 고르지 못하게 한다.
"stay", "stay2", "stay3",
})
def validate_slug(slug: Optional[str]) -> Optional[str]:
"""형식·예약어만 본다(중복은 DB 를 봐야 하므로 서비스가 판단한다).
쓸 수 있으면 None, 아니면 REASON_* 를 돌려준다."""
value = (slug or "").strip()
if len(value) < SLUG_MIN_LEN or len(value) > SLUG_MAX_LEN:
return REASON_LENGTH
# 하이픈 연속은 정규식으로 막지 않는다(가운데 문자 집합이라 통과한다) — 여기서 따로 거른다.
# a--b 는 퓨니코드 접두(xn--)와 모양이 겹쳐서 서브도메인으로 냈을 때 오해를 산다.
if "--" in value:
return REASON_FORMAT
if not SLUG_PATTERN.fullmatch(value):
return REASON_FORMAT
if value in RESERVED_SLUGS:
return REASON_RESERVED
return None
def suggestions(slug: Optional[str], count: int = 9) -> list[str]:
"""`-2`, `-3` … 을 붙인 대안 후보. 실제로 쓸 수 있는지(중복)는 서비스가 DB 로 거른다.
★ 음차하거나 상호를 마음대로 줄이지 않는다 — 서버가 고른 주소는 사장님이 기억하지 못한다.
사장님이 적어낸 이름을 그대로 두고 뒤에 숫자만 붙인다."""
value = (slug or "").strip()
result: list[str] = []
for n in range(2, 2 + max(count, 0)):
suffix = f"-{n}"
# 50자 상한을 넘기지 않도록 앞을 자른다. 자른 끝이 하이픈이면 `a--2` 가 되므로 떼어낸다.
base = value[: SLUG_MAX_LEN - len(suffix)].rstrip("-")
candidate = f"{base}{suffix}"
if validate_slug(candidate) is None:
result.append(candidate)
return result