feat(ssulbox): 공식 링크 오버레이 추가 및 AI 브리핑 대기 제거

This commit is contained in:
김성경 2026-08-24 13:44:45 +09:00
parent 934410a83b
commit 6d46614dcd
8 changed files with 107 additions and 32 deletions

View File

@ -83,7 +83,6 @@ async def create_ssul(
session,
user_uuid=current_user.user_uuid,
scenario=body.scenario,
input_text=body.input,
scenes=body.scenes,
seconds=body.seconds,
store_name=body.store_name,
@ -300,6 +299,7 @@ async def get_content_detail(
description=row.description,
store_name=row.store_name or None,
region=row.region,
official_site_url=row.official_site_url,
created_at=row.created_at,
like_count=like_count,
is_liked_by_me=is_liked,

View File

@ -110,10 +110,14 @@ class SsulContent(Base):
comment="시나리오 코드 (joseon/samgukji/greek/odyssey)",
)
input: Mapped[str] = mapped_column(
Text,
nullable=False,
comment="입력값 (네이버 지도 URL 또는 업장명)",
# castad `marketing.official_site_url` 과 같은 의미다 — 영상 종료 직전 오버레이의 링크.
# 생성 요청 시점에는 알 수 없어 NULL 로 시작하고, 워커가 place 페이지를 크롤링할 때
# 채운다(홈페이지 항목 우선, 없으면 네이버 플레이스 URL). 업장명만 입력해 place URL
# 해석까지 실패하면 끝까지 NULL 이고, 그때는 프론트가 오버레이를 그리지 않는다.
official_site_url: Mapped[Optional[str]] = mapped_column(
String(2048),
nullable=True,
comment="업체 공식 링크 (플레이스 홈페이지 항목 우선, 없으면 네이버 플레이스 URL; 미확보 시 NULL)",
)
# scenes / seconds 는 DB 기본값을 두지 않는다. 기본값(9 / 30)과 허용 범위

View File

@ -119,6 +119,13 @@ class SsulDetailResponse(BaseModel):
description: Optional[str] = Field(None, description="SNS 업로드 설명")
store_name: Optional[str] = Field(None, description="업장명")
region: Optional[str] = Field(None, description="지역명")
official_site_url: Optional[str] = Field(
None,
description=(
"업체 공식 링크 (플레이스 홈페이지 항목 우선, 없으면 네이버 플레이스 URL). "
"영상 종료 직전 오버레이에 쓰며, 미확보 시 null 이라 오버레이를 그리지 않는다"
),
)
created_at: datetime = Field(..., description="생성 일시")
like_count: int = Field(..., description="좋아요 수")
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지")

View File

@ -141,6 +141,10 @@ async def _extract_detail_from_page(page, tries: int = 12) -> dict | None:
continue
detail = _detail_from_state(state)
if detail:
# 공식 링크는 apollo state 의 파싱된 dict 가 아니라 원문 HTML 에서 뽑는다
# (`homepages` 는 GraphQL placeDetail 이 노출하지 않아 castad 도 같은 방식).
# 이미 받아 둔 html 을 재사용하므로 추가 요청·왕복이 없다.
detail["homepage"] = NvMapScraper._extract_homepage_from_html(html)
return detail
await page.wait_for_timeout(700)
return None

View File

@ -42,7 +42,6 @@ async def create_task(
*,
user_uuid: str,
scenario: str,
input_text: str,
scenes: int,
seconds: int,
store_name: Optional[str] = None,
@ -65,7 +64,6 @@ async def create_task(
row = SsulContent(
user_uuid=user_uuid,
scenario=scenario,
input=input_text,
scenes=scenes,
seconds=seconds,
status=SsulTaskStatus.QUEUED.value,
@ -217,15 +215,15 @@ async def fail_task(
async def get_place_info(
session: AsyncSession, content_id: int
) -> tuple[Optional[str], Optional[str], Optional[str]]:
"""(store_name, region, detail_region_info).
) -> tuple[Optional[str], Optional[str], Optional[str], Optional[str]]:
"""(store_name, region, detail_region_info, official_site_url).
크롤링으로 채울 값이 남았는지 판단하는 데 쓴다.
"""
row = await session.get(SsulContent, content_id)
if row is None:
return None, None, None
return row.store_name, row.region, row.detail_region_info
return None, None, None, None
return row.store_name, row.region, row.detail_region_info, row.official_site_url
async def set_place_info(
@ -235,10 +233,14 @@ async def set_place_info(
store_name: Optional[str] = None,
region: Optional[str] = None,
detail_region_info: Optional[str] = None,
official_site_url: Optional[str] = None,
) -> Optional[SsulContent]:
"""크롤링으로 얻은 업장 정보를 채운다. **이미 있는 값은 덮지 않는다.**
사용자가 검색으로 직접 고른 값이 크롤링 추정치보다 정확하므로 우선한다.
`official_site_url` 만 예외로 덮어쓴다 — 호출부가 플레이스 URL 을 먼저 폴백으로
넣어 두고 크롤링에 성공하면 진짜 홈페이지로 승급시키기 때문이다.
"""
row = await session.get(SsulContent, content_id)
if row is None:
@ -249,9 +251,12 @@ async def set_place_info(
row.region = region
if detail_region_info and not row.detail_region_info:
row.detail_region_info = detail_region_info
if official_site_url:
row.official_site_url = official_site_url[:2048]
logger.info(
f"[set_place_info] id={content_id} store={row.store_name!r} "
f"region={row.region!r} detail={(row.detail_region_info or '')[:30]!r}"
f"region={row.region!r} detail={(row.detail_region_info or '')[:30]!r} "
f"site={(row.official_site_url or '')[:60]!r}"
)
return row

View File

@ -144,14 +144,18 @@ async def _update_step(content_id: int, step: int) -> None:
async def _get_place_info(
content_id: int,
) -> tuple[Optional[str], Optional[str], Optional[str]]:
"""현재 저장된 (store_name, region, detail_region_info)."""
) -> tuple[Optional[str], Optional[str], Optional[str], Optional[str]]:
"""현재 저장된 (store_name, region, detail_region_info, official_site_url)."""
async with BackgroundSessionLocal() as session:
return await task_service.get_place_info(session, content_id)
async def _save_place(
content_id: int, store_name: str, region: str, detail: str
content_id: int,
store_name: str,
region: str,
detail: str,
official_site_url: Optional[str] = None,
) -> None:
async with BackgroundSessionLocal() as session:
await task_service.set_place_info(
@ -160,6 +164,7 @@ async def _save_place(
store_name=store_name,
region=region,
detail_region_info=detail,
official_site_url=official_site_url,
)
await session.commit()
@ -249,23 +254,29 @@ def _is_place_url(text: str) -> bool:
def _collect_place_info(content_id: int, job: dict) -> None:
"""비어 있는 업장명·지역을 크롤링으로 채운다(있는 값은 유지).
"""비어 있는 업장명·지역·공식 링크를 크롤링으로 채운다(있는 값은 유지).
**빠진 값이 있을 때만 크롤링한다.** 검색으로 고른 경우 create 시점에 둘 다
채워져 있으므로 브라우저를 띄우지 않는다. 다만 검색 경로에서도 지역만 비는
경우(주소에서 시/군을 못 뽑는 등)가 있어, 존재 여부를 실제로 확인하고 판단한다.
**빠진 값이 있을 때만 크롤링한다.** 검색으로 고른 경우 create 시점에 업장명·지역이
채워져 있으나 공식 링크는 늘 비어 있으므로, 그 경로에서도 이 함수가 크롤링한다
(Playwright 1회, 최대 120초). 링크를 못 얻어도 place URL 폴백은 남는다.
수집 실패는 삼킨다. 이 정보가 없어도 생성은 place_url 만으로 진행된다.
"""
if not _is_place_url(job.get("input", "")):
return
place_url = job.get("input", "")
if not _is_place_url(place_url):
return # 업장명 해석 실패 — 링크로 쓸 값이 없다
try:
store_name, region, detail = _run_db(_get_place_info(content_id))
if store_name and region and detail:
store_name, region, detail, site_url = _run_db(_get_place_info(content_id))
if store_name and region and detail and site_url:
return # 채울 것이 없다
# 크롤링이 실패해도 오버레이가 뜨도록 place URL 을 먼저 폴백으로 저장한다.
# castad 가 `official_site_url or 크롤링 소스 URL` 로 폴백하는 것과 같은 규칙.
if not site_url:
_run_db(_save_place(content_id, "", "", "", place_url))
detail = _run_db(
place_service.fetch_place_detail(job["input"]),
place_service.fetch_place_detail(place_url),
# Playwright 기동 + 상세 파싱까지 DB 위임 기본 타임아웃(60s)보다 길 수 있다
timeout=120,
)
@ -275,14 +286,16 @@ def _collect_place_info(content_id: int, job: dict) -> None:
title = detail.get("title") or ""
road = detail.get("roadAddress") or ""
jibun = detail.get("address") or ""
homepage = detail.get("homepage") or ""
# castad `/home/crawl` 과 동일하게 **도로명·지번을 모두** 넘긴다.
# 도로명에서 시/군 추출이 실패하면 지번으로 재시도한다(한쪽만 넘기면 놓친다).
new_region = extract_region_from_address(road or None, jibun or None)
new_detail = road or jibun # 도로명 우선, 없으면 지번
if title:
job["store_name"] = title
if title or new_region or new_detail:
_run_db(_save_place(content_id, title, new_region, new_detail))
if title or new_region or new_detail or homepage:
# homepage 가 있으면 위에서 넣어 둔 place URL 폴백을 진짜 홈페이지로 승급시킨다.
_run_db(_save_place(content_id, title, new_region, new_detail, homepage))
except Exception as e:
logger.warning(
f"[ssul {content_id}] 업장 정보 수집 실패(생성은 계속): "

View File

@ -0,0 +1,21 @@
-- ============================================================
-- Migration: ssul_content.input → official_site_url 로 전환
-- Date: 2026-08-24
-- Description: 썰박스 상세에도 ADO2 영상과 동일한 '공식 링크 오버레이'를 적용한다.
-- `input`(요청 원본: place URL 또는 업장명)은 INSERT 후 어디서도
-- 읽지 않는 쓰기 전용 컬럼이었다 — 워커는 인메모리 job dict 를 쓴다.
-- 그 자리를 공식 링크(플레이스 홈페이지 우선, 없으면 플레이스 URL)로 돌린다.
-- 관련 코드: app/ssulbox/worker/job_manager.py(_collect_place_info),
-- app/ssulbox/services/place_service.py(_extract_detail_from_page)
-- ============================================================
ALTER TABLE `ssul_content`
CHANGE COLUMN `input` `official_site_url` VARCHAR(2048) NULL
COMMENT '업체 공식 링크 (플레이스 홈페이지 항목 우선, 없으면 네이버 플레이스 URL; 미확보 시 NULL)';
-- ⚠️ 반드시 함께 실행할 것.
-- CHANGE 는 기존 `input` 값을 새 컬럼으로 그대로 옮긴다. 업장명만 입력해 만든 행은
-- URL 이 아닌 문자열("스테이 머뭄" 등)을 갖게 되고, 그대로 두면 상세 화면의 링크
-- href 에 그 값이 들어간다. 기존 콘텐츠는 오버레이 대상이 아니므로(신규 생성분부터
-- 적용) 전부 비운다.
UPDATE `ssul_content` SET `official_site_url` = NULL;

View File

@ -339,6 +339,11 @@ def _parse_ai_briefing(txt):
AI_BRIEFING_TIMEOUT = 18 # getAiBriefing 응답 대기(초). 정상은 ~6s, 이 안에 안 오면 스로틀/지연으로 본다.
# place 홈 진입 후 getAiBriefing '요청' 발사를 기다리는 창(초).
# 브리핑이 없는 업체엔 네이버가 요청 자체를 만들지 않는다 — 실측(2026-08-24, zzz/probe_ai_briefing.py):
# 브리핑 있는 업체는 진입 0.8초 뒤 발사·1.1초에 응답, 없는 업체는 45초를 봐도 0회.
# 이 창을 넘기면 '없음'이 확정이므로 응답을 기다릴 이유도, 새 세션으로 재시도할 이유도 없다.
AI_BRIEFING_REQUEST_WINDOW = 6
AI_BRIEFING_RETRIES = 2 # 응답이 끝내 안 올 때(kind='timeout') 새 세션으로 재시도할 횟수.
AI_BRIEFING_RETRY_DELAY = 4 # 재시도 전 대기(초) — 연속 타격으로 스로틀이 굳는 걸 늦춘다.
@ -348,8 +353,8 @@ async def _fetch(url, headful=False):
끝내 안 오면(kind='timeout', 스로틀/지연 추정) 새 세션으로 최대 AI_BRIEFING_RETRIES 회 재시도한다.
반환은 공개 API 형식인 3-튜플 (store, bullets, cat) — 내부 kind 는 여기서 소비.
kind='briefing' → 브리핑 확보(bullets 채움)
kind='none' → 브리핑 없는 업체 확정(bullets=None → 호출부가 리뷰 폴백)
kind='timeout' → 응답 보류(재시도 가치 있음); 소진 시 bullets=None(→ 리뷰 폴백)."""
kind='none' → 브리핑 없는 업체 확정(요청 미발사 또는 빈 응답; bullets=None → 호출부가 리뷰 폴백)
kind='timeout' → 요청은 나갔으나 응답 보류(재시도 가치 있음); 소진 시 bullets=None(→ 리뷰 폴백)."""
last_store, last_cat = None, None
for i in range(AI_BRIEFING_RETRIES + 1):
store, bullets, cat, kind = await _with_profile_retry(
@ -369,15 +374,16 @@ async def _fetch_once(url, profile, headful=False):
"""네이버 플레이스 진입 → getAiBriefing GraphQL 응답을 가로채 브리핑을 파싱.
반환 4-튜플 (store, bullets, cat, kind):
kind='briefing' → bullets=[불릿...] (브리핑 존재)
kind='none' → bullets=None (aiBriefing null/빈 = 브리핑 없는 업체)
kind='timeout' → bullets=None (응답이 AI_BRIEFING_TIMEOUT 내 안 옴 = 스로틀/지연)."""
kind='none' → bullets=None (요청 미발사 또는 aiBriefing null/빈 = 브리핑 없는 업체)
kind='timeout' → bullets=None (요청은 나갔으나 응답이 AI_BRIEFING_TIMEOUT 내 안 옴 = 스로틀/지연)."""
async with async_playwright() as p:
browser, ctx, page = await _new_page(p, headful=headful, profile=profile)
# getAiBriefing 응답을 진입 전에 미리 리스닝(진입 직후 발사되므로 놓치지 않게).
# getAiBriefing 요청/응답을 진입 전에 미리 리스닝(진입 직후 발사되므로 놓치지 않게).
# parsed: [불릿...] 또는 [](없음 확정) 이면 done, None(무관/파싱실패)이면 계속 대기.
captured = {"bullets": None}
done = asyncio.Event()
requested = asyncio.Event() # getAiBriefing 요청이 실제로 나갔는가
async def on_response(resp):
if done.is_set() or not _is_ai_briefing_response(resp):
@ -392,6 +398,13 @@ async def _fetch_once(url, profile, headful=False):
captured["bullets"] = parsed # [불릿...] 또는 [](없음) → 확정
done.set()
def on_request(req):
if requested.is_set() or "graphql" not in req.url or req.method != "POST":
return
if "getAiBriefing" in (req.post_data or ""):
requested.set()
page.on("request", on_request)
page.on("response", lambda r: asyncio.ensure_future(on_response(r)))
try:
@ -412,7 +425,15 @@ async def _fetch_once(url, profile, headful=False):
await browser.close()
raise
# getAiBriefing 응답을 기다린다(스크롤 불필요 — 진입 직후 XHR 로 발사됨).
# 요청이 창 안에 안 나가면 브리핑 미제공 업체로 확정한다. 예전에는 이 경우도
# 'timeout' 으로 보고 새 세션 재시도를 2회 돌아 업체당 55~65초를 버렸다.
try:
await asyncio.wait_for(requested.wait(), timeout=AI_BRIEFING_REQUEST_WINDOW)
except asyncio.TimeoutError:
await browser.close()
return store, None, cat, "none"
# 요청은 나갔다 → 응답을 기다린다(스크롤 불필요 — 진입 직후 XHR 로 발사됨).
got = True
try:
await asyncio.wait_for(done.wait(), timeout=AI_BRIEFING_TIMEOUT)