feat(ssulbox): 업장 검색을 ADO2 방식으로 전환하고 URL 해석을 워커로 이관
This commit is contained in:
parent
50dc7af5bc
commit
459099998e
@ -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="콘텐츠가 삭제되었습니다.",
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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 []
|
||||
|
||||
@ -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),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user