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)