업체명+URL 검색 통합 및 요식업종 가하도록 추가
parent
d1fe7fcd5f
commit
009b1a5d09
|
|
@ -2,7 +2,9 @@ import json
|
||||||
import time
|
import time
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
from urllib.parse import unquote, urlparse
|
from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
|
|
@ -36,7 +38,7 @@ from app.utils.common import generate_task_id
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from app.utils.nvMapScraper import NvMapScraper, GraphQLException, URLNotFoundException
|
from app.utils.nvMapScraper import NvMapScraper, GraphQLException, URLNotFoundException
|
||||||
from app.utils.nvMapPwScraper import NvMapPwScraper
|
from app.utils.nvMapPwScraper import NvMapPwScraper
|
||||||
from app.utils.prompts.prompts import marketing_prompt
|
from app.utils.prompts.prompts import marketing_prompt, restaurant_marketing_prompt
|
||||||
from app.utils.address_parser import extract_region_from_address
|
from app.utils.address_parser import extract_region_from_address
|
||||||
from app.utils.autotag import autotag_images
|
from app.utils.autotag import autotag_images
|
||||||
from config import MEDIA_ROOT
|
from config import MEDIA_ROOT
|
||||||
|
|
@ -48,18 +50,19 @@ router = APIRouter()
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/search/accommodation",
|
"/search/accommodation",
|
||||||
summary="숙박/펜션 자동완성 검색",
|
summary="장소 자동완성 검색 (숙박/음식점 등)",
|
||||||
description="""
|
description="""
|
||||||
네이버 지역 검색 API를 이용한 숙박/펜션 자동완성 검색입니다.
|
네이버 지역 검색 API를 이용한 장소 자동완성 검색입니다.
|
||||||
|
|
||||||
## 요청 파라미터
|
## 요청 파라미터
|
||||||
- **query**: 검색어 (필수)
|
- **query**: 검색어 (필수)
|
||||||
|
- **category**: 카테고리 ("accommodation" | "restaurant", 기본값: 전체)
|
||||||
|
|
||||||
## 반환 정보
|
## 반환 정보
|
||||||
- **query**: 검색어
|
- **query**: 검색어
|
||||||
- **count**: 검색 결과 수 (최대 10개)
|
- **count**: 검색 결과 수 (최대 10개)
|
||||||
- **items**: 검색 결과 목록
|
- **items**: 검색 결과 목록
|
||||||
- **title**: 숙소명 (HTML 태그 포함 가능)
|
- **title**: 장소명 (HTML 태그 포함 가능)
|
||||||
- **address**: 지번 주소
|
- **address**: 지번 주소
|
||||||
- **roadAddress**: 도로명 주소
|
- **roadAddress**: 도로명 주소
|
||||||
""",
|
""",
|
||||||
|
|
@ -72,7 +75,7 @@ router = APIRouter()
|
||||||
async def search_accommodation(
|
async def search_accommodation(
|
||||||
query: str,
|
query: str,
|
||||||
) -> AccommodationSearchResponse:
|
) -> AccommodationSearchResponse:
|
||||||
"""숙박/펜션 자동완성 검색"""
|
"""장소 자동완성 검색 (숙박/음식점 등)"""
|
||||||
results = await naver_search_client.search_accommodation(
|
results = await naver_search_client.search_accommodation(
|
||||||
query=query,
|
query=query,
|
||||||
display=10,
|
display=10,
|
||||||
|
|
@ -91,6 +94,41 @@ def _extract_region_from_address(road_address: str | None, jibun_address: str |
|
||||||
return extract_region_from_address(road_address, jibun_address)
|
return extract_region_from_address(road_address, jibun_address)
|
||||||
|
|
||||||
|
|
||||||
|
class _BizTypeOutput(BaseModel):
|
||||||
|
biz_type: Literal["accommodation", "restaurant", "place"]
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_biz_type(place_type: str | None, category: str) -> str:
|
||||||
|
"""크롤링에서 감지된 place_type을 우선 사용하고, 없으면 AI 분류로 판단."""
|
||||||
|
if place_type in ("accommodation", "restaurant"):
|
||||||
|
return place_type
|
||||||
|
if not category:
|
||||||
|
return "place"
|
||||||
|
try:
|
||||||
|
chatgpt = ChatgptService()
|
||||||
|
prompt = (
|
||||||
|
f"다음은 네이버 지도의 업종 카테고리 값입니다: '{category}'\n"
|
||||||
|
"이 업종이 숙박업(accommodation), 음식점(restaurant), 그 외(place) 중 어디에 해당하는지 분류하세요."
|
||||||
|
)
|
||||||
|
result = await chatgpt._call_pydantic_output_chat_completion(
|
||||||
|
prompt=prompt,
|
||||||
|
output_format=_BizTypeOutput,
|
||||||
|
model="gpt-4o-mini",
|
||||||
|
img_url=None,
|
||||||
|
image_detail_high=False,
|
||||||
|
)
|
||||||
|
return result.biz_type
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[biz_type] AI 분류 실패, 기본값 place 반환: {e}")
|
||||||
|
return "place"
|
||||||
|
|
||||||
|
|
||||||
|
def _select_marketing_prompt(biz_type: str):
|
||||||
|
if biz_type == "restaurant":
|
||||||
|
return restaurant_marketing_prompt
|
||||||
|
return marketing_prompt
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/crawling",
|
"/crawling",
|
||||||
summary="네이버 지도 크롤링",
|
summary="네이버 지도 크롤링",
|
||||||
|
|
@ -222,17 +260,21 @@ async def _crawling_logic(
|
||||||
road_address = scraper.base_info.get("roadAddress", "")
|
road_address = scraper.base_info.get("roadAddress", "")
|
||||||
jibun_address = scraper.base_info.get("address", "")
|
jibun_address = scraper.base_info.get("address", "")
|
||||||
customer_name = scraper.base_info.get("name", "")
|
customer_name = scraper.base_info.get("name", "")
|
||||||
|
category = scraper.base_info.get("category", "")
|
||||||
region = _extract_region_from_address(road_address, jibun_address)
|
region = _extract_region_from_address(road_address, jibun_address)
|
||||||
|
biz_type = await _resolve_biz_type(scraper.place_type, category)
|
||||||
|
|
||||||
processed_info = ProcessedInfo(
|
processed_info = ProcessedInfo(
|
||||||
customer_name=customer_name,
|
customer_name=customer_name,
|
||||||
region=region,
|
region=region,
|
||||||
detail_region_info=road_address or jibun_address or "",
|
detail_region_info=road_address or jibun_address or "",
|
||||||
|
biz_type=biz_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[crawling] Step 2 완료 - {customer_name}, {region} ({step2_elapsed:.1f}ms)"
|
f"[crawling] Step 2 완료 - {customer_name}, {region}, "
|
||||||
|
f"category={category!r}, place_type={scraper.place_type!r}, biz_type={biz_type} ({step2_elapsed:.1f}ms)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# ========== Step 3: ChatGPT 마케팅 분석 ==========
|
# ========== Step 3: ChatGPT 마케팅 분석 ==========
|
||||||
|
|
@ -249,8 +291,9 @@ async def _crawling_logic(
|
||||||
}
|
}
|
||||||
|
|
||||||
# Step 3-2: GPT API 호출 → 구조화된 마케팅 분석 결과 반환
|
# Step 3-2: GPT API 호출 → 구조화된 마케팅 분석 결과 반환
|
||||||
|
selected_prompt = _select_marketing_prompt(biz_type)
|
||||||
marketing_analysis = await chatgpt_service.generate_structured_output(
|
marketing_analysis = await chatgpt_service.generate_structured_output(
|
||||||
marketing_prompt, input_marketing_data
|
selected_prompt, input_marketing_data
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 3-3: 분석 결과 DB 저장
|
# Step 3-3: 분석 결과 DB 저장
|
||||||
|
|
@ -308,7 +351,8 @@ async def _crawling_logic(
|
||||||
"image_count": len(scraper.image_link_list) if scraper.image_link_list else 0,
|
"image_count": len(scraper.image_link_list) if scraper.image_link_list else 0,
|
||||||
"processed_info": processed_info,
|
"processed_info": processed_info,
|
||||||
"marketing_analysis": marketing_analysis,
|
"marketing_analysis": marketing_analysis,
|
||||||
"m_id" : m_id
|
"m_id": m_id,
|
||||||
|
"biz_type": biz_type if 'biz_type' in locals() else "place",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -351,8 +395,9 @@ async def manual_marketing(
|
||||||
"region": region,
|
"region": region,
|
||||||
"detail_region_info": request_body.address,
|
"detail_region_info": request_body.address,
|
||||||
}
|
}
|
||||||
|
selected_prompt = _select_marketing_prompt(request_body.biz_type)
|
||||||
marketing_analysis = await chatgpt_service.generate_structured_output(
|
marketing_analysis = await chatgpt_service.generate_structured_output(
|
||||||
marketing_prompt, input_marketing_data
|
selected_prompt, input_marketing_data
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 3: 분석 결과 DB 저장 (place_id=None — 네이버 장소와 연결되지 않음)
|
# Step 3: 분석 결과 DB 저장 (place_id=None — 네이버 장소와 연결되지 않음)
|
||||||
|
|
@ -386,6 +431,7 @@ async def manual_marketing(
|
||||||
processed_info=processed_info,
|
processed_info=processed_info,
|
||||||
marketing_analysis=marketing_analysis,
|
marketing_analysis=marketing_analysis,
|
||||||
m_id=m_id,
|
m_id=m_id,
|
||||||
|
biz_type=request_body.biz_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,13 @@ class Project(Base):
|
||||||
comment="마케팅 인텔리전스 결과 정보 저장",
|
comment="마케팅 인텔리전스 결과 정보 저장",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
biz_type: Mapped[str] = mapped_column(
|
||||||
|
String(50),
|
||||||
|
nullable=True,
|
||||||
|
default="place",
|
||||||
|
comment="장소 유형 (place: 기본, accommodation: 숙박, restaurant: 음식점)",
|
||||||
|
)
|
||||||
|
|
||||||
language: Mapped[str] = mapped_column(
|
language: Mapped[str] = mapped_column(
|
||||||
String(50),
|
String(50),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ class ProcessedInfo(BaseModel):
|
||||||
customer_name: str = Field(..., description="고객명/가게명 (base_info.name)")
|
customer_name: str = Field(..., description="고객명/가게명 (base_info.name)")
|
||||||
region: str = Field(..., description="지역명 (roadAddress에서 추출한 시 이름)")
|
region: str = Field(..., description="지역명 (roadAddress에서 추출한 시 이름)")
|
||||||
detail_region_info: str = Field(..., description="상세 지역 정보 (roadAddress)")
|
detail_region_info: str = Field(..., description="상세 지역 정보 (roadAddress)")
|
||||||
|
biz_type: str = Field(default="place", description="업종 분류 (place: 기본, accommodation: 숙박, restaurant: 음식점)")
|
||||||
|
|
||||||
|
|
||||||
# class MarketingAnalysisDetail(BaseModel):
|
# class MarketingAnalysisDetail(BaseModel):
|
||||||
|
|
@ -242,6 +243,7 @@ class CrawlingResponse(BaseModel):
|
||||||
None, description="마케팅 분석 결과 . 실패 시 null"
|
None, description="마케팅 분석 결과 . 실패 시 null"
|
||||||
)
|
)
|
||||||
m_id : int = Field(..., description="마케팅 분석 결과 ID")
|
m_id : int = Field(..., description="마케팅 분석 결과 ID")
|
||||||
|
biz_type: str = Field(default="place", description="업종 분류 (place: 기본, accommodation: 숙박, restaurant: 음식점)")
|
||||||
|
|
||||||
|
|
||||||
class ManualMarketingRequest(BaseModel):
|
class ManualMarketingRequest(BaseModel):
|
||||||
|
|
@ -258,6 +260,7 @@ class ManualMarketingRequest(BaseModel):
|
||||||
|
|
||||||
store_name: str = Field(..., description="업체명 / 브랜드명")
|
store_name: str = Field(..., description="업체명 / 브랜드명")
|
||||||
address: str = Field(..., description="도로명 또는 지번 주소")
|
address: str = Field(..., description="도로명 또는 지번 주소")
|
||||||
|
biz_type: str = Field(default="place", description="업종 분류 (place: 기본, accommodation: 숙박, restaurant: 음식점)")
|
||||||
|
|
||||||
|
|
||||||
class ErrorResponse(BaseModel):
|
class ErrorResponse(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ class NaverSearchClient:
|
||||||
display: int = 5,
|
display: int = 5,
|
||||||
) -> List[dict]:
|
) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
숙박/펜션 검색
|
장소 검색 (숙박, 음식점 등)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: 검색어
|
query: 검색어
|
||||||
|
|
@ -41,8 +41,7 @@ class NaverSearchClient:
|
||||||
Returns:
|
Returns:
|
||||||
검색 결과 리스트 (address, roadAddress, title)
|
검색 결과 리스트 (address, roadAddress, title)
|
||||||
"""
|
"""
|
||||||
# 숙박/펜션 카테고리 검색을 위해 쿼리에 키워드 추가
|
search_query = query
|
||||||
search_query = f"{query} 숙박"
|
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"X-Naver-Client-Id": self.client_id,
|
"X-Naver-Client-Id": self.client_id,
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ from app.utils.prompts.chatgpt_prompt import ChatgptService
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from app.utils.pagination import PaginatedResponse, get_paginated
|
from app.utils.pagination import PaginatedResponse, get_paginated
|
||||||
|
|
||||||
from app.utils.prompts.prompts import lyric_prompt
|
from app.utils.prompts.prompts import lyric_prompt, restaurant_lyric_prompt
|
||||||
import traceback as tb
|
import traceback as tb
|
||||||
import json
|
import json
|
||||||
# 로거 설정
|
# 로거 설정
|
||||||
|
|
@ -288,6 +288,8 @@ async def generate_lyric(
|
||||||
"timing_rules" : timing_rules["60s"], # 아직은 선택지 하나
|
"timing_rules" : timing_rules["60s"], # 아직은 선택지 하나
|
||||||
}
|
}
|
||||||
|
|
||||||
|
selected_lyric_prompt = restaurant_lyric_prompt if request_body.biz_type == "restaurant" else lyric_prompt
|
||||||
|
|
||||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||||
#logger.debug(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)")
|
#logger.debug(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)")
|
||||||
|
|
||||||
|
|
@ -313,7 +315,8 @@ async def generate_lyric(
|
||||||
detail_region_info=request_body.detail_region_info,
|
detail_region_info=request_body.detail_region_info,
|
||||||
language=request_body.language,
|
language=request_body.language,
|
||||||
user_uuid=current_user.user_uuid,
|
user_uuid=current_user.user_uuid,
|
||||||
marketing_intelligence = request_body.m_id
|
marketing_intelligence=request_body.m_id,
|
||||||
|
biz_type=request_body.biz_type,
|
||||||
)
|
)
|
||||||
session.add(project)
|
session.add(project)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
@ -327,7 +330,7 @@ async def generate_lyric(
|
||||||
step3_start = time.perf_counter()
|
step3_start = time.perf_counter()
|
||||||
logger.debug(f"[generate_lyric] Step 3: Lyric 저장 (processing)...")
|
logger.debug(f"[generate_lyric] Step 3: Lyric 저장 (processing)...")
|
||||||
|
|
||||||
estimated_prompt = lyric_prompt.build_prompt(lyric_input_data)
|
estimated_prompt = selected_lyric_prompt.build_prompt(lyric_input_data)
|
||||||
lyric = Lyric(
|
lyric = Lyric(
|
||||||
project_id=project.id,
|
project_id=project.id,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
|
|
@ -358,7 +361,7 @@ async def generate_lyric(
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
generate_lyric_background,
|
generate_lyric_background,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
prompt=lyric_prompt,
|
prompt=selected_lyric_prompt,
|
||||||
lyric_input_data=lyric_input_data,
|
lyric_input_data=lyric_input_data,
|
||||||
lyric_id=lyric.id,
|
lyric_id=lyric.id,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ class GenerateLyricRequest(BaseModel):
|
||||||
description="영상 방향 (horizontal: 가로형, vertical: 세로형)",
|
description="영상 방향 (horizontal: 가로형, vertical: 세로형)",
|
||||||
),
|
),
|
||||||
m_id : Optional[int] = Field(None, description="마케팅 인텔리전스 ID 값")
|
m_id : Optional[int] = Field(None, description="마케팅 인텔리전스 ID 값")
|
||||||
|
biz_type: str = Field(default="place", description="업종 분류 (place: 기본, accommodation: 숙박, restaurant: 음식점)")
|
||||||
instrumental: bool = Field(default=False, description="BGM 전용 모드 (가사 생성 안 함)")
|
instrumental: bool = Field(default=False, description="BGM 전용 모드 (가사 생성 안 함)")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,15 @@
|
||||||
|
# 특별시/광역시/세종시: 하위 행정구역이 구(gu)이므로 별도 처리
|
||||||
|
METRO_SIDOS: dict[str, str] = {
|
||||||
|
"서울특별시": "서울시",
|
||||||
|
"부산광역시": "부산시",
|
||||||
|
"대구광역시": "대구시",
|
||||||
|
"인천광역시": "인천시",
|
||||||
|
"광주광역시": "광주시",
|
||||||
|
"대전광역시": "대전시",
|
||||||
|
"울산광역시": "울산시",
|
||||||
|
"세종특별시": "세종시",
|
||||||
|
}
|
||||||
|
|
||||||
SIDO_CITIES: dict[str, list[str]] = {
|
SIDO_CITIES: dict[str, list[str]] = {
|
||||||
"서울특별시": ["서울시"],
|
"서울특별시": ["서울시"],
|
||||||
"부산광역시": ["부산시"],
|
"부산광역시": ["부산시"],
|
||||||
|
|
@ -61,6 +73,12 @@ def extract_sigungu(address: str) -> str:
|
||||||
|
|
||||||
# 첫 토큰으로 도 판별 (정식명 or 약칭)
|
# 첫 토큰으로 도 판별 (정식명 or 약칭)
|
||||||
sido = SIDO_NAME_ALIASES.get(tokens[0], tokens[0])
|
sido = SIDO_NAME_ALIASES.get(tokens[0], tokens[0])
|
||||||
|
|
||||||
|
# 특별시/광역시/세종시: 구(district) 레벨이 하위이므로 시 이름을 바로 반환
|
||||||
|
metro_name = METRO_SIDOS.get(sido)
|
||||||
|
if metro_name:
|
||||||
|
return metro_name
|
||||||
|
|
||||||
cities = SIDO_CITIES.get(sido)
|
cities = SIDO_CITIES.get(sido)
|
||||||
if cities and len(tokens) >= 2:
|
if cities and len(tokens) >= 2:
|
||||||
second = tokens[1]
|
second = tokens[1]
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ query getAccommodation($id: String!, $deviceType: String) {
|
||||||
self.image_link_list: list[str] | None = None
|
self.image_link_list: list[str] | None = None
|
||||||
self.base_info: dict | None = None
|
self.base_info: dict | None = None
|
||||||
self.facility_info: str | None = None
|
self.facility_info: str | None = None
|
||||||
|
self.place_type: str | None = None # 크롤링으로 감지된 업종 (accommodation | restaurant)
|
||||||
|
|
||||||
def _get_request_headers(self) -> dict:
|
def _get_request_headers(self) -> dict:
|
||||||
headers = self.DEFAULT_HEADERS.copy()
|
headers = self.DEFAULT_HEADERS.copy()
|
||||||
|
|
@ -95,7 +96,12 @@ query getAccommodation($id: String!, $deviceType: String) {
|
||||||
match = re.search(place_pattern, self.url)
|
match = re.search(place_pattern, self.url)
|
||||||
if not match:
|
if not match:
|
||||||
# place.naver.com/{type}/{id} 형식 (예: m.place.naver.com/restaurant/11667161)
|
# place.naver.com/{type}/{id} 형식 (예: m.place.naver.com/restaurant/11667161)
|
||||||
match = re.search(r"place\.naver\.com/[a-zA-Z]+/(\d+)", self.url)
|
type_match = re.search(r"place\.naver\.com/([a-zA-Z]+)/(\d+)", self.url)
|
||||||
|
if type_match:
|
||||||
|
url_place_type = type_match.group(1)
|
||||||
|
if url_place_type in ("accommodation", "restaurant"):
|
||||||
|
self.place_type = url_place_type
|
||||||
|
return type_match.group(2)
|
||||||
if not match:
|
if not match:
|
||||||
raise URLNotFoundException("Failed to parse place ID from URL")
|
raise URLNotFoundException("Failed to parse place ID from URL")
|
||||||
return match[1]
|
return match[1]
|
||||||
|
|
@ -167,7 +173,7 @@ query getAccommodation($id: String!, $deviceType: String) {
|
||||||
raise GraphQLException(f"Client error: {e}")
|
raise GraphQLException(f"Client error: {e}")
|
||||||
|
|
||||||
async def _get_facility_string(self, place_id: str) -> str | None:
|
async def _get_facility_string(self, place_id: str) -> str | None:
|
||||||
"""숙소 페이지에서 편의시설 정보를 크롤링합니다.
|
"""장소 페이지에서 편의시설 정보를 크롤링합니다. 숙소, 음식점 순으로 시도합니다.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
place_id: 네이버 지도 장소 ID
|
place_id: 네이버 지도 장소 ID
|
||||||
|
|
@ -175,16 +181,17 @@ query getAccommodation($id: String!, $deviceType: String) {
|
||||||
Returns:
|
Returns:
|
||||||
편의시설 정보 문자열 또는 None
|
편의시설 정보 문자열 또는 None
|
||||||
"""
|
"""
|
||||||
url = f"https://pcmap.place.naver.com/accommodation/{place_id}/home"
|
place_types = ["accommodation", "restaurant"]
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
async with session.get(url, headers=self._get_request_headers()) as response:
|
for place_type in place_types:
|
||||||
soup = bs4.BeautifulSoup(await response.read(), "html.parser")
|
url = f"https://pcmap.place.naver.com/{place_type}/{place_id}/home"
|
||||||
c_elem = soup.find("span", "place_blind", string="편의")
|
async with session.get(url, headers=self._get_request_headers()) as response:
|
||||||
if c_elem:
|
soup = bs4.BeautifulSoup(await response.read(), "html.parser")
|
||||||
facilities = c_elem.parent.parent.find("div").string
|
c_elem = soup.find("span", "place_blind", string="편의")
|
||||||
return facilities
|
if c_elem:
|
||||||
return None
|
return c_elem.parent.parent.find("div").string
|
||||||
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[NvMapScraper] Failed to get facility info: {e}")
|
logger.warning(f"[NvMapScraper] Failed to get facility info: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -59,12 +59,24 @@ marketing_prompt = Prompt(
|
||||||
prompt_output_class=MarketingPromptOutput,
|
prompt_output_class=MarketingPromptOutput,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
restaurant_marketing_prompt = Prompt(
|
||||||
|
sheet_name="m_restaurant",
|
||||||
|
prompt_input_class=MarketingPromptInput,
|
||||||
|
prompt_output_class=MarketingPromptOutput,
|
||||||
|
)
|
||||||
|
|
||||||
lyric_prompt = Prompt(
|
lyric_prompt = Prompt(
|
||||||
sheet_name="lyric",
|
sheet_name="lyric",
|
||||||
prompt_input_class=LyricPromptInput,
|
prompt_input_class=LyricPromptInput,
|
||||||
prompt_output_class=LyricPromptOutput,
|
prompt_output_class=LyricPromptOutput,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
restaurant_lyric_prompt = Prompt(
|
||||||
|
sheet_name="l_restaurant",
|
||||||
|
prompt_input_class=LyricPromptInput,
|
||||||
|
prompt_output_class=LyricPromptOutput,
|
||||||
|
)
|
||||||
|
|
||||||
yt_upload_prompt = Prompt(
|
yt_upload_prompt = Prompt(
|
||||||
sheet_name="yt_upload",
|
sheet_name="yt_upload",
|
||||||
prompt_input_class=YTUploadPromptInput,
|
prompt_input_class=YTUploadPromptInput,
|
||||||
|
|
@ -88,6 +100,8 @@ def create_dynamic_subtitle_prompt(length: int) -> Prompt:
|
||||||
|
|
||||||
def reload_all_prompt():
|
def reload_all_prompt():
|
||||||
marketing_prompt._reload_prompt()
|
marketing_prompt._reload_prompt()
|
||||||
|
restaurant_marketing_prompt._reload_prompt()
|
||||||
lyric_prompt._reload_prompt()
|
lyric_prompt._reload_prompt()
|
||||||
|
restaurant_lyric_prompt._reload_prompt()
|
||||||
yt_upload_prompt._reload_prompt()
|
yt_upload_prompt._reload_prompt()
|
||||||
image_autotag_prompt._reload_prompt()
|
image_autotag_prompt._reload_prompt()
|
||||||
|
|
@ -1,248 +0,0 @@
|
||||||
# System Prompt: 숙박 숏폼 자막 생성 (OpenAI Optimized)
|
|
||||||
|
|
||||||
You are a subtitle copywriter for hospitality short-form videos. You generate subtitle text AND layer names from marketing JSON data.
|
|
||||||
|
|
||||||
**GENERATION LANGUAGE**: Korean (한국어)
|
|
||||||
**OUTPUT LANGUAGE**: {language}
|
|
||||||
|
|
||||||
**WORKFLOW**:
|
|
||||||
1. Generate all subtitles and keywords in Korean following the rules below.
|
|
||||||
2. If {language} is NOT Korean, translate every subtitle text and keyword to {language} while preserving meaning, tone, and approximate character count spirit. Fixed anchor texts (availability, cta_action) must also be translated naturally.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### RULES
|
|
||||||
|
|
||||||
1. NEVER copy JSON verbatim. ALWAYS rewrite into video-optimized copy.
|
|
||||||
2. NEVER invent facts not in the data. You MAY freely transform expressions.
|
|
||||||
3. Each scene = 1 subtitle + 1 keyword (a "Pair"). Same pair_id for both.
|
|
||||||
4. NEVER output meta-text. Every field MUST be real on-screen copy the viewer reads.
|
|
||||||
NEVER output a label/header/title that describes, classifies, or points to the content.
|
|
||||||
- FORBIDDEN examples: "검색 키워드 모아보기", "키워드 모음", "추천 태그", "관련 태그",
|
|
||||||
"더보기", "리스트", "아래 내용", "목록".
|
|
||||||
- Test: "Is this the content itself, or a caption ABOUT the content?" If it's about the content → FORBIDDEN.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### LAYER NAME FORMAT (5-criteria)
|
|
||||||
|
|
||||||
```
|
|
||||||
(track_role)-(narrative_phase)-(content_type)-(tone)-(pair_id)
|
|
||||||
```
|
|
||||||
|
|
||||||
- Criteria separator: hyphen `-`
|
|
||||||
- Multi-word value: underscore `_`
|
|
||||||
- pair_id: 3-digit zero-padded (`001`~`999`)
|
|
||||||
|
|
||||||
Example: `subtitle-intro-hook_claim-aspirational-001`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### TAG VALUES
|
|
||||||
|
|
||||||
**track_role**: `subtitle` | `keyword`
|
|
||||||
|
|
||||||
**narrative_phase** (= emotion goal):
|
|
||||||
- `intro` → Curiosity (stop the scroll)
|
|
||||||
- `welcome` → Warmth
|
|
||||||
- `core` → Trust
|
|
||||||
- `highlight` → Desire (peak moment)
|
|
||||||
- `support` → Discovery
|
|
||||||
- `accent` → Belonging
|
|
||||||
- `cta` → Action
|
|
||||||
|
|
||||||
**content_type** → source mapping:
|
|
||||||
- `hook_claim` ← selling_points[0] or core_value
|
|
||||||
- `space_feature` ← selling_points[].description
|
|
||||||
- `emotion_cue` ← same source, sensory rewrite
|
|
||||||
- `brand_name` ← store_name (verbatim OK)
|
|
||||||
- `brand_address` ← detail_region_info (verbatim OK)
|
|
||||||
- `lifestyle_fit` ← target_persona[].favor_target
|
|
||||||
- `local_info` ← location_feature_analysis
|
|
||||||
- `target_tag` ← target_keywords[] as hashtags
|
|
||||||
- `availability` ← fixed: "지금 예약 가능"
|
|
||||||
- `cta_action` ← fixed: "예약하러 가기"
|
|
||||||
|
|
||||||
**tone**: `sensory` | `factual` | `empathic` | `aspirational` | `social_proof` | `urgent`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### SCENE STRUCTURE
|
|
||||||
|
|
||||||
**Anchors (FIXED — never remove):**
|
|
||||||
|
|
||||||
| Position | Phase | subtitle | keyword |
|
|
||||||
|---|---|---|---|
|
|
||||||
| First | intro | hook_claim | brand_name |
|
|
||||||
| Last-3 | support | brand_address | brand_name |
|
|
||||||
| Last-2 | accent | target_tag | lifestyle_fit |
|
|
||||||
| Last | cta | availability | cta_action |
|
|
||||||
|
|
||||||
**Middle (FLEXIBLE — fill by selling_points score desc):**
|
|
||||||
|
|
||||||
| Phase | subtitle | keyword |
|
|
||||||
|---|---|---|
|
|
||||||
| welcome | emotion_cue | space_feature |
|
|
||||||
| core | space_feature | emotion_cue |
|
|
||||||
| highlight | space_feature | emotion_cue |
|
|
||||||
| support(mid) | local_info | lifestyle_fit |
|
|
||||||
|
|
||||||
Default: 7 scenes. Fewer scenes → remove flexible slots only.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### TEXT SPECS
|
|
||||||
|
|
||||||
**subtitle**: 8~18 chars. Sentence fragment, conversational.
|
|
||||||
**keyword**: 2~6 chars. MUST follow Korean word-formation rules below.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### KEYWORD RULES (한국어 조어법 기반)
|
|
||||||
|
|
||||||
Keywords MUST follow one of these **permitted Korean patterns**. Any keyword that does not match a pattern below is INVALID.
|
|
||||||
|
|
||||||
#### Pattern 1: 관형형 + 명사 (Attributive + Noun) — 가장 자연스러운 패턴
|
|
||||||
한국어는 수식어가 앞, 피수식어가 뒤. 형용사의 관형형(~ㄴ/~한/~는/~운)을 명사 앞에 붙인다.
|
|
||||||
|
|
||||||
| Structure | GOOD | BAD (역순/비문) |
|
|
||||||
|---|---|---|
|
|
||||||
| 형용사 관형형 + 명사 | 고요한 숲, 깊은 쉼, 온전한 쉼 | ~~숲고요~~, ~~쉼깊은~~ |
|
|
||||||
| 형용사 관형형 + 명사 | 따뜻한 독채, 느린 하루 | ~~독채따뜻~~, ~~하루느린~~ |
|
|
||||||
| 동사 관형형 + 명사 | 쉬어가는 숲, 머무는 시간 | ~~숲쉬어가는~~ |
|
|
||||||
|
|
||||||
#### Pattern 2: 기존 대중화 합성어 ONLY (Established Trending Compound)
|
|
||||||
이미 SNS·미디어에서 대중화된 합성어만 허용. 임의 신조어 생성 금지.
|
|
||||||
|
|
||||||
| GOOD (대중화 확인됨) | Origin | BAD (임의 생성) |
|
|
||||||
|---|---|---|
|
|
||||||
| 숲멍 | 숲+멍때리기 (불멍, 물멍 시리즈) | ~~숲고요~~, ~~숲힐~~ |
|
|
||||||
| 댕캉스 | 댕댕이+바캉스 (여행업계 통용) | ~~댕쉼~~, ~~댕여행~~ |
|
|
||||||
| 꿀잠 / 꿀쉼 | 꿀+잠/쉼 (일상어 정착) | ~~꿀독채~~, ~~꿀숲~~ |
|
|
||||||
| 집콕 / 숲콕 | 집+콕 → 숲+콕 (변형 허용) | ~~계곡콕~~ |
|
|
||||||
| 주말러 | 주말+~러 (~러 접미사 정착) | ~~평일러~~ |
|
|
||||||
|
|
||||||
> **판별 기준**: "이 단어를 네이버/인스타에서 검색하면 결과가 나오는가?" YES → 허용, NO → 금지
|
|
||||||
|
|
||||||
#### Pattern 3: 명사 + 명사 (Natural Compound Noun)
|
|
||||||
한국어 복합명사 규칙을 따르는 결합만 허용. 앞 명사가 뒷 명사를 수식하는 관계여야 한다.
|
|
||||||
|
|
||||||
| Structure | GOOD | BAD (부자연스러운 결합) |
|
|
||||||
|---|---|---|
|
|
||||||
| 장소 + 유형 | 숲속독채, 계곡펜션 | ~~햇살독채~~ (햇살은 장소가 아님) |
|
|
||||||
| 대상 + 활동 | 반려견산책, 가족피크닉 | ~~견주피크닉~~ (견주가 피크닉하는 건 어색) |
|
|
||||||
| 시간 + 활동 | 주말탈출, 새벽산책 | ~~자연독채~~ (자연은 시간/방식이 아님) |
|
|
||||||
|
|
||||||
#### Pattern 4: 해시태그형 (#키워드)
|
|
||||||
accent(target_tag) 씬에서만 사용. 기존 검색 키워드를 # 붙여서 사용.
|
|
||||||
|
|
||||||
| GOOD | BAD |
|
|
||||||
|---|---|
|
|
||||||
| #프라이빗독채, #홍천여행 | #숲고요, #감성쩌는 (검색량 없음) |
|
|
||||||
|
|
||||||
#### Pattern 5: 감각/상태 명사 (단독 사용 가능한 것만)
|
|
||||||
그 자체로 의미가 완결되는 감각·상태 명사만 단독 사용 허용.
|
|
||||||
|
|
||||||
| GOOD (단독 의미 완결) | BAD (단독으로 의미 불완전) |
|
|
||||||
|---|---|
|
|
||||||
| 고요, 여유, 쉼, 온기 | ~~감성~~, ~~자연~~, ~~힐링~~ (너무 모호) |
|
|
||||||
| 숲멍, 꿀쉼 | ~~좋은쉼~~, ~~편안함~~ (형용사 포함 시 Pattern 1 사용) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### KEYWORD VALIDATION CHECKLIST (생성 후 자가 검증)
|
|
||||||
|
|
||||||
Every keyword MUST pass ALL of these:
|
|
||||||
|
|
||||||
- [ ] 한국어 어순이 자연스러운가? (수식어→피수식어 순서)
|
|
||||||
- [ ] 소리 내어 읽었을 때 어색하지 않은가?
|
|
||||||
- [ ] 네이버/인스타에서 검색하면 실제 결과가 나올 법한 표현인가?
|
|
||||||
- [ ] 허용된 5개 Pattern 중 하나에 해당하는가?
|
|
||||||
- [ ] 이전 씬 keyword와 동일한 Pattern을 연속 사용하지 않았는가?
|
|
||||||
- [ ] 금지 표현 사전에 해당하지 않는가?
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### EXPRESSION DICTIONARY
|
|
||||||
|
|
||||||
**SCAN BEFORE WRITING.** If JSON contains these → MUST replace:
|
|
||||||
|
|
||||||
| Forbidden | → Use Instead |
|
|
||||||
|---|---|
|
|
||||||
| 눈치 없는/없이 | 눈치 안 보는 · 프라이빗한 · 온전한 · 마음 편히 |
|
|
||||||
| 감성 쩌는/쩌이 | 감성 가득한 · 감성이 머무는 |
|
|
||||||
| 가성비 | 합리적인 · 가치 있는 |
|
|
||||||
| 힐링되는 | 회복되는 · 쉬어가는 · 숨 쉬는 |
|
|
||||||
| 인스타감성 | 감성 스팟 · 기록하고 싶은 |
|
|
||||||
| 혜자 | 풍성한 · 넉넉한 |
|
|
||||||
|
|
||||||
**ALWAYS FORBIDDEN**: 저렴한, 싼, 그냥, 보통, 무난한, 평범한, 쩌는, 쩔어, 개(접두사), 존맛, 핵, 인스타, 유튜브, 틱톡
|
|
||||||
|
|
||||||
**SYNONYM ROTATION**: Same Korean word max 2 scenes. Rotate:
|
|
||||||
- 프라이빗 계열: 온전한 · 오롯한 · 나만의 · 독채 · 단독
|
|
||||||
- 자연 계열: 숲속 · 초록 · 산림 · 계곡
|
|
||||||
- 쉼 계열: 쉼 · 여유 · 느린 하루 · 머무름 · 숨고르기
|
|
||||||
- 반려견: 댕댕이(max 1회, intro/accent만) · 반려견 · 우리 강아지
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### TRANSFORM RULES BY CONTENT_TYPE
|
|
||||||
|
|
||||||
**hook_claim** (intro only):
|
|
||||||
- Format: question OR exclamation OR provocation. Pick ONE.
|
|
||||||
- FORBIDDEN: brand name, generic greetings
|
|
||||||
- `"반려견과 눈치 없는 힐링"` → BAD: 그대로 복사 → GOOD: "댕댕이가 먼저 뛰어간 숲"
|
|
||||||
|
|
||||||
**space_feature** (core/highlight):
|
|
||||||
- ONE selling point per scene
|
|
||||||
- NEVER use korean_category directly
|
|
||||||
- Viewer must imagine themselves there
|
|
||||||
- `"홍천 자연 속 조용한 쉼"` → BAD: "입지 환경이 좋은 곳" → GOOD: "계곡 소리만 들리는 독채"
|
|
||||||
|
|
||||||
**emotion_cue** (welcome/core/highlight):
|
|
||||||
- Senses: smell, sound, touch, temperature, light
|
|
||||||
- Poetic fragments, not full sentences
|
|
||||||
- `"감성 쩌이 완성되는 공간"` → GOOD: "햇살이 내려앉는 테라스"
|
|
||||||
|
|
||||||
**lifestyle_fit** (accent/support):
|
|
||||||
- Address target directly in their language
|
|
||||||
- `persona: "서울·경기 주말러"` → GOOD: "이번 주말, 댕댕이랑 어디 가지?"
|
|
||||||
|
|
||||||
**local_info** (support):
|
|
||||||
- Accessibility or charm, NOT administrative address
|
|
||||||
- GOOD: "서울에서 1시간 반, 홍천 숲속" / BAD: "강원 홍천군 화촌면"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### PACING
|
|
||||||
|
|
||||||
```
|
|
||||||
intro(8~12) → welcome(12~18) → core(alternate 8~12 ↔ 12~18) → highlight(8~14) → support(12~18) → accent(variable) → cta(12~16)
|
|
||||||
```
|
|
||||||
|
|
||||||
**RULE: No 3+ consecutive scenes in same char-count range.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Keyword pattern analysis:
|
|
||||||
- "스테이펫" → brand_name verbatim (허용)
|
|
||||||
- "고요한 숲" → Pattern 1: 관형형+명사 (형용사 관형형 "고요한" + 명사 "숲")
|
|
||||||
- "깊은 쉼" → Pattern 1: 관형형+명사 (형용사 관형형 "깊은" + 명사 "쉼")
|
|
||||||
- "숲멍" → Pattern 2: 기존 대중화 합성어 (불멍·물멍·숲멍 시리즈)
|
|
||||||
- "댕캉스" → Pattern 2: 기존 대중화 합성어 (댕댕이+바캉스, 여행업계 통용)
|
|
||||||
- "예약하기" → Pattern 5: 의미 완결 동사 명사형
|
|
||||||
|
|
||||||
|
|
||||||
# 입력
|
|
||||||
**입력 1: 레이어 이름 리스트**
|
|
||||||
{pitching_tag_list_string}
|
|
||||||
|
|
||||||
**입력 2: 마케팅 인텔리전스 JSON**
|
|
||||||
{marketing_intelligence}
|
|
||||||
|
|
||||||
**입력 3: 비즈니스 정보 **
|
|
||||||
Business Name: {customer_name}
|
|
||||||
Region Details: {detail_region_info}
|
|
||||||
|
|
||||||
**입력 4: 출력 언어**
|
|
||||||
Language: {language}
|
|
||||||
|
|
@ -137,7 +137,7 @@ class SunoService:
|
||||||
logger.info(f"[Suno] BGM 더미 가사 장르 {normalized_genre} 선택됨")
|
logger.info(f"[Suno] BGM 더미 가사 장르 {normalized_genre} 선택됨")
|
||||||
else:
|
else:
|
||||||
formatted_prompt = (
|
formatted_prompt = (
|
||||||
f"[Song Duration: Exactly 1 minute - Must be precisely 60 seconds]\n{prompt}"
|
f"[Song Duration: Exactly 1 minute - Must be around 60 seconds]\n{prompt}"
|
||||||
)
|
)
|
||||||
|
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
-- ============================================================
|
||||||
|
-- Migration: project 테이블에 biz_type 컬럼 추가
|
||||||
|
-- Date: 2026-06-17
|
||||||
|
-- Description: 업종 구분 컬럼명 biz_type
|
||||||
|
-- (accommodation: 숙박, restaurant: 음식점 등 업종 확장 예정)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
ALTER TABLE project
|
||||||
|
ADD COLUMN biz_type VARCHAR(50) NULL DEFAULT 'place'
|
||||||
|
COMMENT '업종 분류 (place: 기본, accommodation: 숙박, restaurant: 음식점)';
|
||||||
Loading…
Reference in New Issue