o2o-site-AEO/solution/backend/services/build_service.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

392 lines
21 KiB
Python

"""정적 빌드 + 발행 — BUILD 잡이 하는 일.
스냅샷 조립 → 빌드(HTML + JSON-LD) → 발행 검수 게이트 → site_version 기록
★ 정적 빌드다. DB 는 여기서만 읽고, 그 결과가 site_versions.snapshot 에 박제된다.
방문자는 DB 와 만나지 않는다.
★ 개별 재빌드 단위다. 사이트 1,000개에서 전체 재빌드는 못 쓴다 —
places.content_updated_at 이 바뀐 사업장만 다시 빌드하면 된다.
★ 게이트를 통과하지 못하면 버전은 FAILED 로 남고 발행되지 않는다. 사유가 publish_logs 에 남는다.
"""
import os
import uuid
from sqlalchemy import select
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import place_channels, places, site_publish_logs, site_versions, sites
from common.enums import (
BuildStatus,
DBWRType,
ErrorType,
PlaceCategory,
PlaceStatus,
PublishAction,
PublishResult,
SiteStatus,
)
from common.logger import LOG
from common.utils.gtime import GTime
from crud.site_crud import SiteCRUD
from crud.place_crud import PlaceCRUD
from services import (
azure_static,
indexnow,
publish_gate,
render_service,
seo_keywords,
site_payload,
site_thumbnail,
song_service,
)
from services.local_content_service import LocalContentService
from services.site_payload import emit_payload
from services.snapshot import build_snapshot
from common.job_errors import PermanentJobError
_site_crud = SiteCRUD()
_place_crud = PlaceCRUD()
# 렌더러 subprocess 가 끝나기를 기다리는 시간(사진 내려받기 포함).
# ★ 넉넉해야 한다. 짧으면 멀쩡한 발행이 "렌더 시간 초과"로 실패한다 — 처음 보는 사진을
# 내려받는 발행은 몇 초가 더 걸린다(mirrorMedia, prerender.ts).
RENDER_TIMEOUT_SEC = float(os.environ.get("RENDER_TIMEOUT_SEC") or 180)
class BuildAborted(PermanentJobError):
"""재시도해도 소용없는 중단 — 잡의 last_error 로 남는다."""
async def ensure_site(place_id: str) -> "sites":
"""사업장의 사이트 행을 보장한다(없으면 만든다). 사업장당 1개."""
pid = uuid.UUID(place_id)
err, site = await DB_SESSION_MNG.execute_lambda(
sites.DBType(), DBWRType.DB_READ.value, lambda s: _site_crud.get_site_by_place(s, pid)
)
if err == ErrorType.SUCCESS and site is not None:
return site
row = sites(place_id=pid, status=SiteStatus.DRAFT.value)
run_err = await DB_SESSION_MNG.execute_lambda_run(
[sites.DBType()], [lambda s: _site_crud.add_site(s, row)]
)
if run_err != ErrorType.SUCCESS:
raise BuildAborted(f"사이트 생성 실패: {run_err.name}")
return row
async def load_channel_links(place_id: str) -> list:
"""채널 링크(야놀자·네이버 플레이스·인스타…).
스냅샷에 담기지 않는 유일한 발행 재료라 여기서 읽어 payload 로 넘긴다 — 재빌드(run_build)
도 롤백(rollback_service.run_rollback)도 같은 함수를 쓴다. **스냅샷에는 안 싣는다** —
롤백이 옛 스냅샷으로 다시 구워도 링크는 항상 지금 확정된 것을 보여줘야 한다(끊긴 링크를
옛 버전째 되살리면 안 된다).
★ 실패해도 빈 목록으로 진행한다 — 링크가 없다고 발행을 막을 이유가 없다."""
err, rows = await DB_SESSION_MNG.execute_lambda(
place_channels.DBType(),
DBWRType.DB_READ.value,
lambda s: DB_SESSION_MNG.execute(
s,
select(place_channels).where(
place_channels.place_id == uuid.UUID(place_id),
place_channels.deleted == False, # noqa: E712
),
),
)
return list(rows) if err == ErrorType.SUCCESS else []
async def _log(site_id, version_id, action: PublishAction, result: PublishResult, gate=None, actor=None):
"""발행 시도를 기록한다. 거부됐으면 사유와 상세를 그대로 남긴다 — 운영자가 뭘 고칠지 알아야 한다."""
row = site_publish_logs(
site_id=site_id,
site_version_id=version_id,
action=action.value,
result=result.value,
reject_reason=(gate.reason.value if gate is not None and gate.reason else None),
detail=(gate.as_log() if gate is not None and not gate.passed else None),
actor_user_id=uuid.UUID(actor) if actor else None,
)
await DB_SESSION_MNG.execute_lambda_run([site_publish_logs.DBType()], [lambda s: _site_crud.add_log(s, row)])
async def run_build(job: dict) -> dict:
"""BUILD 잡 핸들러. payload: {place_id, owner_user_id, publish?, requested_by?}
publish=True 면 게이트를 통과했을 때 바로 발행까지 한다."""
payload = job["payload"]
place_id = payload["place_id"]
owner_user_id = payload["owner_user_id"]
want_publish = bool(payload.get("publish"))
err, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(),
DBWRType.DB_READ.value,
lambda s: _place_crud.get_place(s, uuid.UUID(owner_user_id), uuid.UUID(place_id)),
)
if err != ErrorType.SUCCESS or place is None:
raise BuildAborted(f"사업장을 찾을 수 없다: {place_id}")
site = await ensure_site(place_id)
# ★ 주변 정보(맛집·관광지·축제·코스)는 빌드 시점에 업장 좌표로 새로 받는다 — 발행본은 정적이라
# 이때 받은 값이 실린다. 실패해도 빌드는 계속한다: 곁들이 정보가 사장님 사이트 발행을 막을 이유가 없고,
# place_contents 는 직전 값을 그대로 갖고 있다.
try:
synced = await LocalContentService().sync_place(place)
if not synced.result.success:
LOG.w(f"[build] place={place_id} 주변정보 갱신 건너뜀(직전 값 사용): {synced.msg}")
except Exception as ex: # noqa: BLE001 — 곁들이 정보 실패가 빌드를 죽이면 안 된다
LOG.w(f"[build] place={place_id} 주변정보 갱신 실패(직전 값 사용): {type(ex).__name__}: {ex}")
# ★ 발행이면 **노래를 먼저 만들고** 스냅샷을 뜬다 (2026-09-11 결정).
# 순서가 뒤집히면(먼저 굽고 나중에 붙이기) 발행 직후의 사이트에는 노래가 없고 몇 분 뒤
# 조용히 생긴다 — 사장님이 [사이트 열기] 로 보는 첫 화면에 그 기능이 빠져 있다.
# 값은 발행이 30초~3분 늦어지는 것이고(Suno 폴링 상한 5분), 그건 감수한다.
# ★ 실패해도 빌드는 계속한다. 주변 정보와 같은 규칙이다 — 곁들이 하나가 사장님 사이트
# 발행을 막을 이유가 없다. 노래 없이 나가고, 사유는 아래 로그와 place_songs 에 남는다.
# ★ 미리보기 빌드(publish=False)에는 만들지 않는다 — 유료 호출이라 눌러 보는 것만으로
# 비용이 나가면 안 된다.
song_result: dict | None = None
if want_publish:
try:
song_result = await song_service.ensure_song(place_id, owner_user_id)
LOG.i(f"[build] place={place_id} 노래 — {song_result}")
except Exception as ex: # noqa: BLE001 — 노래 실패가 발행을 죽이면 안 된다
song_result = {"error": f"{type(ex).__name__}: {ex}"}
LOG.w(f"[build] place={place_id} 노래 실패(노래 없이 발행): {type(ex).__name__}: {ex}")
# ★ 일정(LLM)은 **여기서 직접** 부른다. 이건 잡이라 기다리는 사람이 없다 —
# 캔버스 경로가 잡으로 넘기는 것과 사정이 다르다(local_content_service._ensure_region_stories).
# 이미 있는 기간은 부르지 않으므로 매 빌드가 유료 호출이 되지는 않는다.
try:
from services.itinerary_llm_service import ensure_generated
made = await ensure_generated(place)
if made["counts"]:
LOG.i(f"[build] place={place_id} 일정 생성 {made['counts']}")
except Exception as ex: # noqa: BLE001 — 곁들이 정보 실패가 빌드를 죽이면 안 된다
LOG.w(f"[build] place={place_id} 일정 생성 실패(직전 값 사용): {type(ex).__name__}: {ex}")
snapshot = await build_snapshot(place)
# ★ 메타 태그용 검색 키워드(SiteOntology). **스냅샷에 싣는다** — payload 는 스냅샷만 보고 만들고,
# "이 버전에 어떤 키워드가 나갔나" 가 site_versions.snapshot 에 남는다(services/seo_keywords 머리주석).
# ★ 실패해도 빌드는 계속한다. 주변 정보·노래와 같은 규칙이다 — 키워드 없이 예전 제목·메타로 나간다.
# ★ 재빌드(publish=False)에도 부른다. 로컬 임베딩이라 비용이 없고, 재빌드한 버전과 발행한 버전의
# 제목이 갈리면 "눌러 본 것과 나간 것이 다르다" 가 된다.
seo: dict | None = None
try:
seo = await seo_keywords.fetch(place_id, snapshot)
except Exception as ex: # noqa: BLE001 — 키워드 실패가 발행을 죽이면 안 된다
LOG.w(f"[build] place={place_id} 검색 키워드 실패(키워드 없이 발행): {type(ex).__name__}: {ex}")
if seo:
snapshot["seo"] = seo
v_err, version_no = await DB_SESSION_MNG.execute_lambda(
site_versions.DBType(), DBWRType.DB_READ.value, lambda s: _site_crud.next_version_no(s, site.site_id)
)
if v_err != ErrorType.SUCCESS:
raise BuildAborted(f"버전 번호 조회 실패: {v_err.name}")
version = site_versions(
site_id=site.site_id,
version=version_no,
build_status=BuildStatus.BUILDING.value,
snapshot=snapshot,
)
add_err = await DB_SESSION_MNG.execute_lambda_run(
[site_versions.DBType()], [lambda s: _site_crud.add_version(s, version)]
)
if add_err != ErrorType.SUCCESS:
raise BuildAborted(f"버전 생성 실패: {add_err.name}")
result = {"place_id": place_id, "site_id": str(site.site_id), "version": version_no,
"site_version_id": str(version.site_version_id)}
# 잡 결과에 남긴다 — "노래가 왜 없나" 를 잡 하나만 열어 보면 알 수 있어야 한다.
if song_result is not None:
result["song"] = song_result
# "제목이 왜 예전 그대로인가" 도 같다 — 키워드가 실렸으면 잡 결과에 보인다(없으면 로그의 [seo] 줄).
if seo is not None:
result["seo"] = seo
now = GTime.UTC()
async def _fail(reason: str, gate: publish_gate.GateResult | None = None, extra: dict | None = None):
"""버전을 FAILED 로 남기고 사유를 기록한다. 발행하지 않는다."""
await DB_SESSION_MNG.execute_lambda_claim(
site_versions.DBType(),
lambda s: _site_crud.finish_version(
s, version.site_version_id,
{"build_status": BuildStatus.FAILED.value, "build_error": reason[:2000], **(extra or {})},
),
)
action = PublishAction.PUBLISH if gate is not None else PublishAction.REBUILD
outcome = PublishResult.REJECTED if gate is not None else PublishResult.FAILED
await _log(site.site_id, version.site_version_id, action, outcome, gate, payload.get("requested_by"))
result["build_status"] = "FAILED"
result["error"] = reason
LOG.w(f"[build] place={place_id} v{version_no} 실패: {reason}")
return result
# ---- 1차 게이트: 렌더 없이 판정 가능한 것 ----
# ★ 미검증 fact 는 payload 를 쓰기 전에 막는다. 렌더러에 넘긴 뒤에 막으면 검증 안 된 값이
# 디스크에 한 번 나갔다 들어오는 셈이 된다.
place_name = str((snapshot.get("place") or {}).get("name") or "").strip()
if not place_name:
return await _fail("상호명이 없다 — 사이트를 만들 수 없다")
if (snapshot.get("place") or {}).get("category") is None:
return await _fail("업종이 없다 — 어떤 스키마로 렌더할지 알 수 없다")
facts_gate = publish_gate.check_facts_verified(snapshot["facts"])
if not facts_gate.passed:
result["gate"] = {"passed": False, **facts_gate.as_log()}
return await _fail(f"{facts_gate.reason.name}: {facts_gate.as_log()}", facts_gate)
# ---- 렌더러에 넘긴다 ----
# ★ 여기가 "발행 기록"과 "실제 페이지"를 잇는 자리다. 렌더러(solution/site)의 유일한 입력이
# 이 payload JSON 이고, 그게 굽는 HTML 이 방문자와 크롤러가 보는 유일한 페이지다.
# ★ payload 에 실릴 발행 상태를 미리 맞춘다. 렌더러는 이 값으로 datePublished 를 굽는데,
# 발행 뒤에 payload 를 쓰던 예전 순서에서는 그게 채워져 있었다. 순서가 바뀌었다고
# 페이지의 발행일이 비면 AI 검색이 보는 신선도 신호가 사라진다.
# ★ 게이트에서 떨어지면 DB 에는 반영하지 않는다(아래에서 실제로 쓸 때만 저장한다).
if want_publish:
site.status = SiteStatus.PUBLISHED.value
site.published_at = site.published_at or now
links = await load_channel_links(place_id)
payload_path = await emit_payload(place, snapshot, site, version, links, publish=want_publish)
if not payload_path:
return await _fail("payload 를 쓰지 못했다 — 렌더러에 넘길 입력이 없다")
result["payload_path"] = payload_path
slug = site_payload.publish_slug(place, site)
# ---- 렌더러를 직접 돌린다 ----
# ★ 게이트는 **실제로 나갈 HTML** 을 보고 판정해야 한다. 렌더러가 자기 산출물을 대조해
# 구조화 데이터 불일치와 고유 콘텐츠 수를 보고서로 돌려준다.
# ★ payload=False(미발행)면 렌더러는 `out/versions/<slug>/<version>/` 에만 굽고 공개
# 주소(`out/s/<slug>`)는 건드리지 않는다 — emit_payload 에 실은 publish 플래그가 정한다.
try:
report = await render_service.render_site(payload_path, version_no, RENDER_TIMEOUT_SEC)
except render_service.RenderFailed as ex:
# ★ 발행하지 않는다. 페이지가 있는지 확인하지 못한 채 "발행됨"으로 남기면
# 사장님은 [사이트 열기] 를 눌러야 404 로 알게 된다.
return await _fail(str(ex))
mismatches = list(report.get("mismatches") or [])
# ★ None(재지 못했다)과 0(재 봤더니 0건)을 뭉개지 않는다. 게이트는 raw 를 보고,
# 기록·화면에는 0 으로 떨어뜨린다. 뭉개면 디스크 오류가 NO_UNIQUE_CONTENT 로 둔갑한다.
unique_count_raw = report.get("uniqueContentCount")
unique_count = unique_count_raw or 0
jsonld = report.get("jsonld") or []
result["unique_content_count"] = unique_count
result["mismatches"] = mismatches[:20]
stamp = {"jsonld": jsonld, "unique_content_count": unique_count}
# ---- 2차 게이트: 렌더 산출물 기준 ----
# ★ 렌더가 실패했더라도 게이트를 **먼저** 돌린다. 렌더러가 페이지 쓰기를 거부한 이유가
# 대개 게이트 사유(고유 콘텐츠 0건·구조화 데이터 불일치)이기 때문이다.
# 여기서 사유를 정확히 골라야 site_publish_logs 에 '무엇을 고쳐야 하는지' 가 남는다 —
# 전부 "렌더 실패"로 뭉뚱그리면 운영자가 손댈 곳을 알 수 없다.
gate = publish_gate.evaluate(
PlaceCategory(place.category), snapshot["facts"], unique_count_raw, mismatches
)
result["gate"] = {"passed": gate.passed, **gate.as_log()}
if not gate.passed:
return await _fail(f"{gate.reason.name}: {gate.as_log()}", gate, stamp)
if not report.get("ok"):
# ★ 렌더러가 거부한 이유에 사유 코드를 붙인다. evaluate() 는 얇은 콘텐츠로 막지
# 않지만 렌더러는 스팸 판정을 피하려 페이지 쓰기를 거부한다 — 그 사유를 "렌더 실패"
# 로 뭉개면 화면이 NO_UNIQUE_CONTENT 문구를 못 고르고, 사장님은 손댈 곳을 모른다.
thin = publish_gate.check_unique_content(unique_count_raw)
if not thin.passed:
# 위에서 evaluate 결과로 채워 둔 gate 를 덮는다 — 화면(GateRejectCard)은 이 값으로
# 문구를 고르는데, passed=True 인 채로 두면 "서버 검수를 통과하지 못했습니다" 만 뜬다.
result["gate"] = {"passed": False, **thin.as_log()}
return await _fail(f"{thin.reason.name}: {thin.as_log()}", thin, stamp)
# 나머지는 게이트로 설명되지 않는 실패(디스크·번들·payload 파손). 재시도가 의미 있다.
return await _fail(str(report.get("error") or "렌더 실패"), None, stamp)
# DB 발행 상태를 바꾸기 전에 정적 파일을 외부 저장소에 올린다.
thumbnail_url = None
if want_publish:
try:
await render_service.activate_site(slug, version_no)
azure_result = await azure_static.publish(slug)
except (azure_static.AzurePublishError, render_service.RenderFailed) as ex:
return await _fail(str(ex), None, stamp)
if azure_result:
result["azure"] = azure_result
# ★ 페이지가 실제로 올라간 뒤에 썸네일을 남긴다 — 없는 페이지의 그림을 쇼케이스에 걸지 않는다.
# 실패해도 발행은 성공이다(스크린샷이 아니라 대표 사진이라, 없으면 글자 카드로 떨어진다).
thumbnail_url = await site_thumbnail.store(slug, snapshot, version_no)
if thumbnail_url:
result["thumbnail_url"] = thumbnail_url
# ★ 정적 파일이 올라간 **뒤에** 통보한다. 먼저 알리면 크롤러가 옛 파일을 가져간다.
# 실패해도 발행은 성공이다 — 색인 통보는 부수 효과이고, 다음 발행에서 다시 보낸다.
indexnow_result = await indexnow.submit(slug)
if indexnow_result:
result["indexnow"] = indexnow_result
# ---- 빌드 성공 ----
# ★ jsonld·고유콘텐츠 수는 **렌더러가 실제로 내보낸 값**이다. 백엔드가 따로 계산하지 않는다 —
# 따로 계산하던 시절엔 게이트가 통과시킨 근거와 실제 페이지가 어긋날 수 있었다.
await DB_SESSION_MNG.execute_lambda_claim(
site_versions.DBType(),
lambda s: _site_crud.finish_version(
s, version.site_version_id,
{
"build_status": BuildStatus.BUILT.value,
"jsonld": jsonld,
"unique_content_count": unique_count,
"built_at": now,
"build_error": None,
},
),
)
result["build_status"] = "BUILT"
result["routes"] = report.get("routes")
if want_publish:
# 썸네일은 발행 상태 전이와 같은 UPDATE 에 싣는다 — 못 만들었으면 키를 넣지 않아
# 지난 발행의 그림이 그대로 남는다(NULL 로 밀어 카드를 비우지 않는다).
site_update = {
"status": SiteStatus.PUBLISHED.value,
"current_version_id": version.site_version_id,
"published_at": now,
**({"thumbnail_url": thumbnail_url} if thumbnail_url else {}),
}
await DB_SESSION_MNG.execute_lambda_claim(
sites.DBType(),
lambda s: _site_crud.update_site(s, site.site_id, site_update),
)
# ★ 사업장 상태도 같이 올린다. 여기서 안 올리면 places.status 는 영원히 REVIEW 라,
# 발행을 마친 가게가 사업장 목록에서 '발행 전'으로 남는다 — 사장님은 목록만 보고
# 자기 사이트가 나갔는지 알 수 없다. 목록은 사이트 행을 읽지 않는다(N+1).
await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(),
lambda s: _place_crud.update_place(
s, uuid.UUID(owner_user_id), uuid.UUID(place_id), {"status": PlaceStatus.PUBLISHED.value}
),
)
await _log(site.site_id, version.site_version_id, PublishAction.PUBLISH, PublishResult.SUCCESS, None,
payload.get("requested_by"))
site.status = SiteStatus.PUBLISHED.value
site.current_version_id = version.site_version_id
site.published_at = now
if thumbnail_url:
site.thumbnail_url = thumbnail_url
result["published"] = True
LOG.i(f"[build] place={place_id} v{version_no} 발행 완료 "
f"(고유 콘텐츠 {unique_count}건 · {report.get('routes')} 페이지)")
else:
await _log(site.site_id, version.site_version_id, PublishAction.REBUILD, PublishResult.SUCCESS, None,
payload.get("requested_by"))
LOG.i(f"[build] place={place_id} v{version_no} 빌드 완료(미발행)")
version.build_status = BuildStatus.BUILT.value
version.built_at = now
return result