feat(ssulbox): 업장 검색을 ADO2 방식으로 전환하고 URL 해석을 워커로 이관

This commit is contained in:
김성경 2026-08-11 11:03:04 +09:00
parent 50dc7af5bc
commit 459099998e
5 changed files with 296 additions and 198 deletions

View File

@ -5,10 +5,11 @@ castad 는 `/api/*` prefix 를 쓰지 않고 도메인별 prefix 를 쓰므로 `
인증은 castad `get_current_user` 그대로 쓴다(원본의 auth 라우터·JWT 폐기).
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, select
from app.credit.exceptions import InsufficientCreditError
from app.database.like_cache import get_like_count, is_user_liked, set_like_count
from app.database.session import AsyncSessionLocal, get_session
from app.ssulbox.constants import ORPHAN_STATUSES, is_generation_available
from app.ssulbox.exceptions import GenerationUnavailableError, TaskNotFoundError
@ -17,13 +18,16 @@ from app.ssulbox.schemas.ssulbox_schema import (
SsulActiveTasksResponse,
SsulCreateRequest,
SsulCreateResponse,
SsulPlaceSearchResponse,
SsulDeleteResponse,
SsulDetailResponse,
SsulTaskStatus,
)
from app.ssulbox.services import place_service, task_service
from app.ssulbox.services import task_service
from app.ssulbox.worker import job_manager
from app.user.dependencies.auth import get_current_user
from app.user.dependencies.auth import get_current_user, get_current_user_optional
from app.user.models import User
# 좋아요는 castad video_reaction 에 병합돼 있다 (썰박스 행은 content_id 가 채워짐)
from app.video.models import VideoReaction
from app.utils.logger import get_logger
from config import ssulbox_settings
from sqlalchemy.ext.asyncio import AsyncSession
@ -33,33 +37,6 @@ logger = get_logger("ssulbox")
router = APIRouter(prefix="/ssul", tags=["Ssulbox"])
@router.get(
"/search/place",
response_model=SsulPlaceSearchResponse,
summary="업장 검색 (네이버 지도)",
description="""
업장명으로 네이버 지도 후보를 검색합니다.
castad `/search/accommodation`(네이버 **검색 API**) 달리 **`place_url` 포함**합니다.
생성 파이프라인이 네이버 지도 place 페이지를 크롤링하므로 URL 필요합니다.
- 동명 업장은 주소로 구분됩니다.
- 검색 실패·타임아웃 목록을 반환합니다(사용자는 네이버 링크를 직접 붙여넣을 있습니다).
- Playwright 크롤링이라 걸립니다.
""",
responses={
200: {"description": "검색 성공 (결과 없음도 200)"},
401: {"description": "인증 실패"},
},
)
async def search_place(
query: str = Query(..., min_length=2, description="업장명"),
limit: int = Query(default=8, ge=1, le=20),
current_user: User = Depends(get_current_user),
) -> SsulPlaceSearchResponse:
items = await place_service.search_places(query, limit=limit)
return SsulPlaceSearchResponse(query=query, count=len(items), items=items)
@router.post(
"/create",
@ -120,8 +97,16 @@ async def create_ssul(
# 커밋이 끝난 뒤에 큐잉한다 — 워커가 아직 없는 행을 조회하면 안 된다.
# create_job 은 앱 이벤트 루프를 캡처하므로 반드시 요청 핸들러에서 호출한다.
# place URL 해석은 워커가 한다 — 10초 이상 걸려 요청을 막으면 안 된다.
job_manager.create_job(
content_id, body.scenario, body.input, body.scenes, body.seconds
content_id,
body.scenario,
body.input,
body.scenes,
body.seconds,
store_name=body.store_name,
road_address=body.road_address,
address=body.address,
)
return SsulCreateResponse(
@ -192,3 +177,124 @@ async def get_task(
if row is None or row.user_uuid != current_user.user_uuid:
raise TaskNotFoundError()
return SsulTaskStatus.model_validate(row)
@router.get(
"/{content_id}",
response_model=SsulDetailResponse,
summary="썰박스 콘텐츠 공개 상세",
description="""
완성된 썰박스 콘텐츠의 상세 정보를 반환합니다. **비로그인도 접근 가능**합니다
(공유 링크 `/ssul/{content_id}` API 씁니다 castad `/video/{video_id}` 동일한 패턴).
- `status=done` 이고 삭제되지 않은 콘텐츠만 조회됩니다.
- `is_liked_by_me` 비로그인이면 항상 false 입니다.
- 댓글은 `GET /comment/video/{content_id}?type=ssul` 따로 조회합니다.
""",
responses={
200: {"description": "조회 성공"},
404: {"description": "콘텐츠를 찾을 수 없음"},
},
)
async def get_content_detail(
content_id: int,
current_user: User | None = Depends(get_current_user_optional),
session: AsyncSession = Depends(get_session),
) -> SsulDetailResponse:
row = await session.get(SsulContent, content_id)
if (
row is None
or row.is_deleted
or row.status != "done"
or not row.video_url
):
raise TaskNotFoundError()
# 좋아요 수: Redis 우선, 미스면 DB 집계 후 캐시에 채운다 (castad 상세와 동일)
like_count = await get_like_count(content_id, ctype="ssul")
if like_count is None:
like_count = (
await session.execute(
select(func.count(VideoReaction.id)).where(
VideoReaction.content_id == content_id
)
)
).scalar() or 0
await set_like_count(content_id, like_count, ctype="ssul")
is_liked = False
if current_user:
cached = await is_user_liked(
content_id, current_user.user_uuid, ctype="ssul"
)
if cached is None:
# user-set 콜드 미스 — 응답 정확성만 필요하므로 DB 존재 확인으로 대체
is_liked = (
await session.execute(
select(VideoReaction.id).where(
VideoReaction.content_id == content_id,
VideoReaction.user_uuid == current_user.user_uuid,
)
)
).scalar_one_or_none() is not None
else:
is_liked = cached
return SsulDetailResponse(
content_id=row.id,
scenario=row.scenario,
video_url=row.video_url,
store_name=row.store_name or None,
region=row.region,
created_at=row.created_at,
like_count=like_count,
is_liked_by_me=is_liked,
)
@router.delete(
"/{content_id}",
response_model=SsulDeleteResponse,
summary="썰박스 콘텐츠 소프트 삭제",
description="""
본인 소유의 썰박스 콘텐츠를 소프트 삭제합니다 (`is_deleted=True`, 데이터는 유지).
castad `DELETE /archive/videos/{video_id}` 동일한 정책입니다.
- 진행 (queued/running) 잡은 삭제할 없습니다 완료·실패 후에 지워야
워커·크레딧 환불 경로와 충돌하지 않습니다.
""",
responses={
200: {"description": "삭제 성공"},
401: {"description": "인증 실패"},
404: {"description": "콘텐츠를 찾을 수 없음 (남의 것 포함)"},
409: {"description": "진행 중인 잡은 삭제 불가"},
},
)
async def delete_content(
content_id: int,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> SsulDeleteResponse:
row = await session.get(SsulContent, content_id)
# 남의 것이면 존재 여부를 노출하지 않고 동일하게 404
if row is None or row.is_deleted or row.user_uuid != current_user.user_uuid:
raise TaskNotFoundError()
# 진행 중인 잡을 지우면 워커가 완료 시점에 삭제된 행을 done 으로 되살리거나,
# 실패 환불 경로와 꼬인다. 터미널 상태에서만 허용한다.
if row.status in ("queued", "running"):
raise HTTPException(
status_code=409,
detail="생성이 진행 중입니다. 완료된 뒤에 삭제해주세요.",
)
row.is_deleted = True
await session.commit()
logger.info(
f"[delete_content] id={content_id} user={current_user.user_uuid}"
)
return SsulDeleteResponse(
success=True,
content_id=content_id,
message="콘텐츠가 삭제되었습니다.",
)

View File

@ -24,6 +24,15 @@ SCENARIO_ENGINE: Final[dict[str, str]] = {
ENGINE_SCENARIO: Final[dict[str, str]] = {v: k for k, v in SCENARIO_ENGINE.items()}
#: 시나리오 한글 표시명. SNS 업로드 SEO 프롬프트 입력({scenario_name})에 쓴다.
#: 프론트 i18n(ssulbox.scenario.*.name)과 값을 맞춘다.
SCENARIO_NAMES: Final[dict[str, str]] = {
"joseon": "조선왕",
"samgukji": "삼국지",
"greek": "그리스·로마 신화",
"odyssey": "오디세이",
}
SCENARIOS: Final[tuple[str, ...]] = tuple(SCENARIO_ENGINE)

View File

@ -10,27 +10,9 @@ from app.ssulbox.constants import SCENARIOS
ScenarioLiteral = Literal["joseon", "samgukji", "greek", "odyssey"]
# =============================================================================
# 장소 검색
# =============================================================================
class SsulPlaceItem(BaseModel):
"""네이버 지도 검색 후보.
castad `/search/accommodation` 응답과 달리 **place_url 포함**한다.
generator URL place 페이지를 크롤링하기 때문이다.
"""
title: str = Field(..., description="업장명")
category: str = Field(default="", description="업종")
address: str = Field(default="", description="표시용 주소 (동명 업장 구분에 사용)")
roadAddress: str = Field(default="", description="도로명 주소")
place_url: str = Field(..., description="네이버 지도 place URL (생성 파이프라인 입력)")
class SsulPlaceSearchResponse(BaseModel):
query: str
count: int
items: list[SsulPlaceItem]
# 장소 검색 스키마는 두지 않는다 — 업장 검색은 ADO2 와 동일하게
# `/search/accommodation`(네이버 검색 API)을 쓰고, place URL 은 생성 요청 시
# 서버가 해석한다(2026-07-31 전환). 썰박스 전용 검색 엔드포인트는 제거됐다.
# =============================================================================
@ -48,13 +30,18 @@ class SsulCreateRequest(BaseModel):
...,
min_length=2,
max_length=500,
description="네이버 지도 place URL 또는 업장명",
description=(
"네이버 지도 place URL 또는 업장명. "
"자동완성으로 고른 경우 store_name/address 가 함께 오며, "
"그때는 서버가 ADO2 와 동일한 방식으로 place URL 을 해석한다."
),
)
scenes: int = Field(default=9, ge=4, le=20, description="장면 수")
seconds: int = Field(default=30, ge=20, le=90, description="장면당 초 길이")
# 검색으로 업장을 고른 경우 프론트가 함께 보낸다(`/ssul/search/place` 결과).
# 통합 목록의 업장명 표시와 store_name/region 필터가 이 값에 의존한다.
# 자동완성(`/search/accommodation`)으로 업장을 고른 경우 프론트가 함께 보낸다.
# 세 가지에 쓰인다: ① place URL 해석 ② 통합 목록의 업장명 표시
# ③ store_name/region 필터.
# place URL 을 직접 붙여넣은 경우에는 없으며, 그때 store_name 은 생성 로그의
# `■ 가게:` 마커로 뒤늦게 채운다(region 은 주소가 없어 채울 수 없다).
store_name: str | None = Field(
@ -105,13 +92,39 @@ class SsulActiveTasksResponse(BaseModel):
items: list[SsulTaskStatus]
class SsulDetailResponse(BaseModel):
"""`GET /ssul/{content_id}` 공개 상세 응답.
castad `VideoDetailResponse` 같은 필드 구성에 `scenario` 더했다
(프론트가 시나리오 표지·라벨을 그리는 쓴다). 공유 링크로 들어온
**비로그인 사용자도 있다** `is_liked_by_me` 비로그인이면 항상 False.
"""
content_id: int = Field(..., description="콘텐츠 고유 ID")
scenario: str = Field(..., description="시나리오 코드")
video_url: str = Field(..., description="완성 영상 URL")
store_name: Optional[str] = Field(None, description="업장명")
region: Optional[str] = Field(None, description="지역명")
created_at: datetime = Field(..., description="생성 일시")
like_count: int = Field(..., description="좋아요 수")
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지")
class SsulDeleteResponse(BaseModel):
"""`DELETE /ssul/{content_id}` 응답. castad 삭제 응답과 같은 형태."""
success: bool
content_id: int
message: str
__all__ = [
"SCENARIOS",
"ScenarioLiteral",
"SsulActiveTasksResponse",
"SsulCreateRequest",
"SsulCreateResponse",
"SsulPlaceItem",
"SsulPlaceSearchResponse",
"SsulDeleteResponse",
"SsulDetailResponse",
"SsulTaskStatus",
]

View File

@ -1,26 +1,27 @@
# -*- coding: utf-8 -*-
"""네이버 지도 업장 검색 (키 불필요, Playwright).
"""네이버 지도 업장 정보 조회 (Playwright).
`map.naver.com/p/search/{query}` 검색 리스트 iframe(`pcmap.place.naver.com/place/list`)
안에 있는 `__APOLLO_STATE__` 파싱해, 동명 업장들을 주소로 구분한 후보 목록을 만든다.
후보는 `place_url`(map.naver.com/p/entry/place/{id}) 가지므로, 사용자가 하나를 고르면
URL 그대로 생성 파이프라인(generator/naver.py) 넘겨 정확히 가게를 크롤링한다.
가지를 한다:
1. `resolve_place_url` 업장명(+주소) place URL.
**castad `NvMapPwScraper.get_place_id_url` 재사용**한다(ADO2 자동완성과 동일 경로).
2. `fetch_place_detail` place URL 업장명·주소.
생성 store_name/region 채우는 쓴다. place 상세의 `__APOLLO_STATE__`
`PlaceDetailBase:{id}` 파싱한다.
**castad `/search/accommodation` 으로 대체할 없다.** 그쪽은 네이버 *검색 API*
`title`/`address`/`roadAddress` 주고 `place_url` 없는데, generator place 페이지를
크롤링하므로 URL 반드시 필요하다. (castad `NvMapPwScraper` 후보=NAVER API,
place_id 해석=Playwright 2단계를 밟지만 여기서는 지도만으로 번에 얻는다.)
**후보 목록 검색은 여기 없다.** 예전에는 지도 검색 iframe 크롤링해 후보를
받았지만 **17** 걸려, ADO2 동일하게 네이버 검색 API(`/search/accommodation`,
~0.2) 자동완성하고 place URL 제출 1건만 해석하도록 바꿨다(2026-07-31).
"""
import asyncio
import json
import sys
from urllib.parse import quote
from playwright.async_api import async_playwright
from app.utils.logger import get_logger
# URL 정규화(단축링크 해석·place_id 추출)는 castad 크롤러 것을 그대로 쓴다.
# 무거운 scrap() 은 쓰지 않는다 — fetch_place_detail docstring 참조.
from app.utils.nvMapPwScraper import NvMapPwScraper
from app.utils.nvMapScraper import NvMapScraper, URLNotFoundException
logger = get_logger("ssulbox")
@ -66,87 +67,42 @@ def _extract_apollo_state(html: str) -> dict | None:
return None
def _candidates_from_state(state: dict, limit: int) -> list[dict]:
out: list[dict] = []
for key, obj in state.items():
if not key.startswith("PlaceListBusinessesItem:"):
continue
if not isinstance(obj, dict):
continue
name = obj.get("name")
pid = obj.get("id")
if not (name and pid and str(pid).isdigit()):
continue
out.append(
{
"title": name,
"category": obj.get("category") or "",
# 표시용 주소는 '서울 성동구 금호동3가' 같은 commonAddress 를 우선(동명 구분에 최적)
"address": obj.get("commonAddress") or obj.get("fullAddress") or "",
"roadAddress": obj.get("fullAddress") or obj.get("roadAddress") or "",
"place_url": f"https://map.naver.com/p/entry/place/{pid}",
}
)
if len(out) >= limit:
break
return out
async def resolve_place_url(
title: str, address: str = "", road_address: str = ""
) -> str | None:
"""업장명(+주소)으로 네이버 지도 place URL 을 해석한다.
**castad `NvMapPwScraper.get_place_id_url` 그대로 재사용한다** ADO2 자동완성이
쓰는 바로 경로다(`app/home` `_autocomplete_logic`). allSearch API 응답을
캡처해 이름·주소 유사도로 후보를 고르고, 실패하면 정제주소 업체명 단독
컨텍스트 재생성 순으로 3 재시도한다.
async def _search(query: str, limit: int) -> list[dict]:
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
args=["--disable-blink-features=AutomationControlled", "--no-sandbox"],
썰박스 자체 지도 크롤링(`search_places`) 쓰지 않는 이유: 그쪽은 후보 목록을
받으려고 iframe 렌더를 기다려 **17** 걸린다. ADO2 후보 탐색을 네이버
검색 API(0.2) 하고 여기서는 확정된 1건만 해석하므로 훨씬 빠르고,
안티봇 차단 대응까지 이미 들어 있다.
실패하면 None. 호출부는 사용자가 링크를 직접 붙여넣는 우회 경로를 남겨둔다.
"""
title = (title or "").strip()
if not title:
return None
try:
selected = {
"title": title,
"address": address or "",
"roadAddress": road_address or address or "",
}
async with NvMapPwScraper() as scraper:
url = await scraper.get_place_id_url(selected)
logger.info(f"[resolve_place_url] {title!r}{url}")
return url
except Exception as e:
logger.error(
f"[resolve_place_url] FAILED {title!r} - {type(e).__name__}: {e}",
exc_info=True,
)
try:
ctx = await browser.new_context(
user_agent=DESKTOP_UA, locale="ko-KR", timezone_id="Asia/Seoul",
viewport={"width": 1280, "height": 800},
extra_http_headers={"Accept-Language": "ko-KR,ko;q=0.9"},
)
page = await ctx.new_page()
await page.goto(
f"https://map.naver.com/p/search/{quote(query)}",
wait_until="domcontentloaded", timeout=40000,
)
# 검색 리스트 iframe 이 뜰 때까지 대기(최대 ~12초)
frame = None
for _ in range(24):
for f in page.frames:
if "pcmap.place.naver.com/place/list" in f.url:
frame = f
break
if frame:
break
# 단일 결과면 곧바로 place 상세로 리다이렉트됨 → 후보 1개로 처리.
# 이때도 **주소를 반드시 채운다**. 예전에는 빈 문자열을 돌려줬는데,
# 그러면 프론트가 주소를 못 보내고 지역(region)이 NULL 이 되어
# 그 콘텐츠가 통합 목록의 지역 필터에서 영구 제외된다.
if "/place/" in page.url and "/search/" not in page.url:
detail = await _extract_detail_from_page(page)
if detail:
detail["place_url"] = page.url
return [detail]
# 상세 파싱까지 실패하면 최소 정보라도 준다(생성은 가능해야 한다)
return [{"title": query, "category": "", "address": "",
"roadAddress": "", "place_url": page.url}]
await page.wait_for_timeout(500)
if not frame:
return []
# apollo state 가 채워질 시간을 조금 더 준다
html = await frame.content()
state = _extract_apollo_state(html)
for _ in range(6):
if state and any(k.startswith("PlaceListBusinessesItem:") for k in state):
break
await page.wait_for_timeout(600)
html = await frame.content()
state = _extract_apollo_state(html)
if not state:
return []
return _candidates_from_state(state, limit)
finally:
await browser.close()
return None
def _detail_from_state(state: dict) -> dict | None:
@ -284,44 +240,3 @@ async def fetch_place_detail(
return None
def _search_blocking(query: str, limit: int) -> list[dict]:
"""별도 스레드에서 자체 이벤트 루프로 Playwright 실행.
Windows 웹서버 루프(Selector) 서브프로세스를 띄워 Playwright 즉시 실패한다.
함수는 워커 스레드에서 Proactor 루프(윈도우) 새로 만들어 위에서 돌린다.
"""
if sys.platform == "win32":
loop = asyncio.ProactorEventLoop()
else:
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(_search(query, limit))
finally:
loop.close()
async def search_places(query: str, limit: int = 8, timeout: float = 35.0) -> list[dict]:
"""업장명으로 네이버 지도 후보 목록을 반환. 실패/타임아웃이면 빈 리스트.
실패해도 예외를 올리지 않는다 사용자에게는 네이버 링크를 직접 붙여넣는
우회 경로가 있으므로 흐름을 막지 않는다. 다만 원본은 예외를 통째로 삼켜
디버깅이 불가능했으므로 로그는 남긴다.
"""
query = (query or "").strip()
if len(query) < 2:
return []
try:
results = await asyncio.wait_for(
asyncio.to_thread(_search_blocking, query, limit), timeout=timeout
)
logger.info(f"[search_places] query='{query}'{len(results)}")
return results
except asyncio.TimeoutError:
logger.warning(f"[search_places] TIMEOUT ({timeout}s) query='{query}'")
return []
except Exception as e:
logger.error(
f"[search_places] FAILED query='{query}' - {type(e).__name__}: {e}",
exc_info=True,
)
return []

View File

@ -58,7 +58,15 @@ _loop: Optional[asyncio.AbstractEventLoop] = None
def create_job(
content_id: int, scenario: str, input_text: str, scenes: int, seconds: int
content_id: int,
scenario: str,
input_text: str,
scenes: int,
seconds: int,
*,
store_name: str | None = None,
road_address: str | None = None,
address: str | None = None,
) -> None:
"""잡을 큐에 넣는다.
@ -79,6 +87,11 @@ def create_job(
"output": None,
"job_dir": None, # generator 가 stdout 으로 알려준다. 실패 정리 대상
"store_name": None, # 검색을 안 거친 경우 생성 로그에서 주워온다
# 자동완성으로 고른 값. place URL 해석(_resolve_input_url)에만 쓴다 —
# DB 저장은 create_task 가 이미 했으므로 여기서는 힌트일 뿐이다.
"store_name_hint": store_name,
"road_address_hint": road_address,
"address_hint": address,
"error": None,
"timings": {},
}
@ -262,6 +275,40 @@ def _collect_place_info(content_id: int, job: dict) -> None:
)
def _resolve_input_url(job: dict) -> None:
"""업장명 입력을 네이버 지도 place URL 로 바꾼다(자동완성 선택 경로).
generator place 페이지를 크롤링하므로 URL 있어야 정확한 가게를 잡는다.
해석은 castad `NvMapPwScraper` 경로를 재사용한다(ADO2 자동완성과 동일).
실패하면 원래 입력(업장명) 그대로 둔다 generator 업장명만으로도
네이버 브리핑을 시도한다. 정확도는 떨어지지만 생성을 막지는 않는다.
"""
raw = (job.get("input") or "").strip()
store = (job.get("store_name_hint") or "").strip()
if not store or _is_place_url(raw):
return
try:
url = _run_db(
place_service.resolve_place_url(
store,
address=job.get("address_hint") or "",
road_address=job.get("road_address_hint") or "",
),
# Playwright 기동 + 최대 3단 재시도라 DB 위임 기본 타임아웃보다 길다
timeout=120,
)
if url:
job["input"] = url
logger.info(f"[_resolve_input_url] {store!r}{url}")
else:
logger.warning(f"[_resolve_input_url] 해석 실패, 업장명으로 진행 - {store!r}")
except Exception as e:
logger.warning(
f"[_resolve_input_url] 실패(업장명으로 진행): {type(e).__name__}: {e}"
)
def _run(content_id: int) -> None:
"""워커 스레드 본체. subprocess 를 감독하며 stdout 마커로 진행을 파싱한다."""
global _running
@ -269,6 +316,23 @@ def _run(content_id: int) -> None:
proc: Optional[subprocess.Popen] = None
try:
_run_db(_mark_running(content_id))
job["status"] = "running"
# 업장명만 들어온 경우(자동완성 선택) place URL 로 바꾼다.
# **요청 핸들러가 아니라 여기서 하는 이유**: 해석에 10초 이상 걸려
# `/ssul/create` 응답이 그만큼 막히고 사용자가 폼에 묶인다.
# 워커로 옮기면 요청은 즉시 끝나고 진행 화면이 바로 뜬다.
_resolve_input_url(job)
# 업장명·주소를 **항상** 확보한다. 검색으로 고르지 않고 place URL 을
# 붙여넣은 경우 create 시점에 아무것도 없으므로 여기서 크롤링한다.
# 생성 **전에** 하는 이유: 목록에 이름 없는 카드가 뜨는 구간을 없앤다.
# 실패해도 생성은 계속한다(목록 표시·필터용 부가 정보다).
_collect_place_info(content_id, job)
# ⚠️ 커맨드는 위 두 단계가 끝난 **뒤에** 만든다 — job["input"] 이 갱신되므로
# 먼저 만들면 해석 전 값(업장명)이 그대로 엔진에 넘어간다.
cmd, engine_dir = _build_command(job)
env = dict(os.environ)
@ -277,15 +341,6 @@ def _run(content_id: int) -> None:
if key:
env["GEMINI_API_KEY"] = key
_run_db(_mark_running(content_id))
job["status"] = "running"
# 업장명·주소를 **항상** 확보한다. 검색으로 고르지 않고 place URL 을
# 붙여넣은 경우 create 시점에 아무것도 없으므로 여기서 크롤링한다.
# 생성 **전에** 하는 이유: 목록에 이름 없는 카드가 뜨는 구간을 없앤다.
# 실패해도 생성은 계속한다(목록 표시·필터용 부가 정보다).
_collect_place_info(content_id, job)
proc = subprocess.Popen(
cmd,
cwd=str(engine_dir),