수집한 객실·이용 정보가 발행 화면에 연결되지 않던 경로를 보완하고, 숙소 소개와 지역 맛집 표시를 개선한다. - NOL 브라우저 수집 어댑터와 수집·반영 스크립트 추가 - 크롤링 fact 즉시 노출 및 직접 입력·정정값 보호 - 이용안내 항목별 구조화와 기존 표 연결, 원문 UI 비표시 - 군산 한일옥 고정 등록과 지역 맛집 탐색·보강 경로 추가 - 숙소 소개 요약, 히어로 문구, 지역 콘텐츠·목업 표시 개선 검증: 작업 트리 기준 site 타입·린트·빌드 및 안내 렌더링 테스트 통과, PC·모바일 화면 확인. 스테이징 diff 공백 검사 통과. 사용자 요청에 따라 현재 스테이징된 55개 파일만 포함하며 미스테이징 문서·테스트 등은 제외.
362 lines
19 KiB
Python
362 lines
19 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_report, 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
|
|
|
|
_site_crud = SiteCRUD()
|
|
_place_crud = PlaceCRUD()
|
|
|
|
# 렌더러가 이 버전을 굽고 보고서를 돌려줄 때까지 기다리는 시간.
|
|
# ★ 넉넉해야 한다. 짧으면 멀쩡한 발행이 "렌더 대기 초과"로 실패한다 —
|
|
# 렌더러는 payload 를 2초 주기로 보고, 사이트 하나 굽는 데 1초 남짓 걸린다.
|
|
RENDER_TIMEOUT_SEC = float(os.environ.get("RENDER_TIMEOUT_SEC") or 180)
|
|
|
|
|
|
class BuildAborted(RuntimeError):
|
|
"""재시도해도 소용없는 중단 — 잡의 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_links(place_id: str) -> list:
|
|
"""채널 링크(야놀자·네이버 플레이스·인스타…).
|
|
|
|
스냅샷에 담기지 않는 유일한 발행 재료라 여기서 읽어 payload 로 넘긴다.
|
|
★ 실패해도 빈 목록으로 진행한다 — 링크가 없다고 발행을 막을 이유가 없다."""
|
|
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)
|
|
|
|
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
|
|
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_links(place_id)
|
|
payload_path = await emit_payload(place, snapshot, site, version, links)
|
|
if not payload_path:
|
|
return await _fail("payload 를 쓰지 못했다 — 렌더러에 넘길 입력이 없다")
|
|
result["payload_path"] = payload_path
|
|
|
|
slug = site_payload.publish_slug(place, site)
|
|
|
|
# ---- 렌더 결과를 기다린다 ----
|
|
# ★ 게이트는 **실제로 나갈 HTML** 을 보고 판정해야 한다. 렌더러가 자기 산출물을 대조해
|
|
# 구조화 데이터 불일치와 고유 콘텐츠 수를 보고서로 돌려준다.
|
|
report = await render_report.wait_for(slug, version_no, RENDER_TIMEOUT_SEC)
|
|
if report is None:
|
|
# ★ 발행하지 않는다. 페이지가 있는지 확인하지 못한 채 "발행됨"으로 남기면
|
|
# 사장님은 [사이트 열기] 를 눌러야 404 로 알게 된다.
|
|
return await _fail(
|
|
f"렌더 결과를 {RENDER_TIMEOUT_SEC}초 안에 받지 못했다 — 렌더러가 도는지 확인이 필요하다"
|
|
)
|
|
|
|
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:
|
|
azure_result = await azure_static.publish(slug)
|
|
except azure_static.AzurePublishError 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
|
|
|