From 6d46614dcdd5655e1fb744880f02b1b07f3f86f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Mon, 24 Aug 2026 13:44:45 +0900 Subject: [PATCH] =?UTF-8?q?feat(ssulbox):=20=EA=B3=B5=EC=8B=9D=20=EB=A7=81?= =?UTF-8?q?=ED=81=AC=20=EC=98=A4=EB=B2=84=EB=A0=88=EC=9D=B4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20AI=20=EB=B8=8C=EB=A6=AC=ED=95=91=20?= =?UTF-8?q?=EB=8C=80=EA=B8=B0=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/ssulbox/api/routers/v1/content.py | 2 +- app/ssulbox/models.py | 12 ++++-- app/ssulbox/schemas/ssulbox_schema.py | 7 ++++ app/ssulbox/services/place_service.py | 4 ++ app/ssulbox/services/task_service.py | 19 +++++---- app/ssulbox/worker/job_manager.py | 41 ++++++++++++------- ...tion_2026_08_24_ssul_official_site_url.sql | 21 ++++++++++ generator/naver.py | 33 ++++++++++++--- 8 files changed, 107 insertions(+), 32 deletions(-) create mode 100644 docs/database-schema/migration_2026_08_24_ssul_official_site_url.sql diff --git a/app/ssulbox/api/routers/v1/content.py b/app/ssulbox/api/routers/v1/content.py index 29706fc..1f22f94 100644 --- a/app/ssulbox/api/routers/v1/content.py +++ b/app/ssulbox/api/routers/v1/content.py @@ -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, diff --git a/app/ssulbox/models.py b/app/ssulbox/models.py index b35b867..13ad7d5 100644 --- a/app/ssulbox/models.py +++ b/app/ssulbox/models.py @@ -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)과 허용 범위 diff --git a/app/ssulbox/schemas/ssulbox_schema.py b/app/ssulbox/schemas/ssulbox_schema.py index 93b32fd..d8bebe1 100644 --- a/app/ssulbox/schemas/ssulbox_schema.py +++ b/app/ssulbox/schemas/ssulbox_schema.py @@ -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="현재 로그인 사용자가 좋아요를 눌렀는지") diff --git a/app/ssulbox/services/place_service.py b/app/ssulbox/services/place_service.py index 0ecf6d8..f546e18 100644 --- a/app/ssulbox/services/place_service.py +++ b/app/ssulbox/services/place_service.py @@ -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 diff --git a/app/ssulbox/services/task_service.py b/app/ssulbox/services/task_service.py index 0031df0..45a9630 100644 --- a/app/ssulbox/services/task_service.py +++ b/app/ssulbox/services/task_service.py @@ -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 diff --git a/app/ssulbox/worker/job_manager.py b/app/ssulbox/worker/job_manager.py index e92aa1c..9ebf507 100644 --- a/app/ssulbox/worker/job_manager.py +++ b/app/ssulbox/worker/job_manager.py @@ -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}] 업장 정보 수집 실패(생성은 계속): " diff --git a/docs/database-schema/migration_2026_08_24_ssul_official_site_url.sql b/docs/database-schema/migration_2026_08_24_ssul_official_site_url.sql new file mode 100644 index 0000000..c8b0759 --- /dev/null +++ b/docs/database-schema/migration_2026_08_24_ssul_official_site_url.sql @@ -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; diff --git a/generator/naver.py b/generator/naver.py index 9538cd3..7b9f70e 100644 --- a/generator/naver.py +++ b/generator/naver.py @@ -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)