Compare commits

..

7 Commits

Author SHA1 Message Date
jaehwang ba26284451 Merge branch 'main' into scraper-poc 2026-01-13 15:50:58 +09:00
Dohyun Lim 3f75b6d61d add facilities from result of crawling 2026-01-12 16:50:16 +09:00
Dohyun Lim b84c07c325 update toml 2026-01-12 14:29:50 +09:00
Dohyun Lim 94aae50564 update crawler for short url 2026-01-12 13:46:28 +09:00
Dohyun Lim 2b777f5314 remove .gitkeep 2026-01-09 10:31:30 +09:00
Dohyun Lim 1199eca649 upgrade marketing template 2026-01-09 10:30:03 +09:00
Dohyun Lim 073777081e add logs for tracing processing task 2026-01-08 14:05:44 +09:00
95 changed files with 24175 additions and 22969 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

BIN
app/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -1,5 +1,16 @@
import logging
import traceback
from functools import wraps
from typing import Any, Callable, TypeVar
from fastapi import FastAPI, HTTPException, Request, Response, status
from fastapi.responses import JSONResponse
from sqlalchemy.exc import SQLAlchemyError
# 로거 설정
logger = logging.getLogger(__name__)
T = TypeVar("T")
class FastShipError(Exception):
@ -61,6 +72,194 @@ class DeliveryPartnerCapacityExceeded(FastShipError):
status = status.HTTP_406_NOT_ACCEPTABLE
# =============================================================================
# 데이터베이스 관련 예외
# =============================================================================
class DatabaseError(FastShipError):
"""Database operation failed"""
status = status.HTTP_503_SERVICE_UNAVAILABLE
class DatabaseConnectionError(DatabaseError):
"""Database connection failed"""
status = status.HTTP_503_SERVICE_UNAVAILABLE
class DatabaseTimeoutError(DatabaseError):
"""Database operation timed out"""
status = status.HTTP_504_GATEWAY_TIMEOUT
# =============================================================================
# 외부 서비스 관련 예외
# =============================================================================
class ExternalServiceError(FastShipError):
"""External service call failed"""
status = status.HTTP_502_BAD_GATEWAY
class GPTServiceError(ExternalServiceError):
"""GPT API call failed"""
status = status.HTTP_502_BAD_GATEWAY
class CrawlingError(ExternalServiceError):
"""Web crawling failed"""
status = status.HTTP_502_BAD_GATEWAY
class BlobStorageError(ExternalServiceError):
"""Azure Blob Storage operation failed"""
status = status.HTTP_502_BAD_GATEWAY
class CreatomateError(ExternalServiceError):
"""Creatomate API call failed"""
status = status.HTTP_502_BAD_GATEWAY
# =============================================================================
# 예외 처리 데코레이터
# =============================================================================
def handle_db_exceptions(
error_message: str = "데이터베이스 작업 중 오류가 발생했습니다.",
):
"""데이터베이스 예외를 처리하는 데코레이터.
Args:
error_message: 오류 발생 반환할 메시지
Example:
@handle_db_exceptions("사용자 조회 중 오류 발생")
async def get_user(user_id: int):
...
"""
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
@wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
try:
return await func(*args, **kwargs)
except HTTPException:
# HTTPException은 그대로 raise
raise
except SQLAlchemyError as e:
logger.error(f"[DB Error] {func.__name__}: {e}")
logger.error(traceback.format_exc())
print(f"[DB Error] {func.__name__}: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=error_message,
)
except Exception as e:
logger.error(f"[Unexpected Error] {func.__name__}: {e}")
logger.error(traceback.format_exc())
print(f"[Unexpected Error] {func.__name__}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="서비스 처리 중 예기치 않은 오류가 발생했습니다.",
)
return wrapper
return decorator
def handle_external_service_exceptions(
service_name: str = "외부 서비스",
error_message: str | None = None,
):
"""외부 서비스 호출 예외를 처리하는 데코레이터.
Args:
service_name: 서비스 이름 (로그용)
error_message: 오류 발생 반환할 메시지
Example:
@handle_external_service_exceptions("GPT")
async def call_gpt():
...
"""
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
@wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
try:
return await func(*args, **kwargs)
except HTTPException:
raise
except Exception as e:
msg = error_message or f"{service_name} 서비스 호출 중 오류가 발생했습니다."
logger.error(f"[{service_name} Error] {func.__name__}: {e}")
logger.error(traceback.format_exc())
print(f"[{service_name} Error] {func.__name__}: {e}")
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=msg,
)
return wrapper
return decorator
def handle_api_exceptions(
error_message: str = "요청 처리 중 오류가 발생했습니다.",
):
"""API 엔드포인트 예외를 처리하는 데코레이터.
Args:
error_message: 오류 발생 반환할 메시지
Example:
@handle_api_exceptions("가사 생성 중 오류 발생")
async def generate_lyric():
...
"""
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
@wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
try:
return await func(*args, **kwargs)
except HTTPException:
raise
except SQLAlchemyError as e:
logger.error(f"[API DB Error] {func.__name__}: {e}")
logger.error(traceback.format_exc())
print(f"[API DB Error] {func.__name__}: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="데이터베이스 연결에 문제가 발생했습니다.",
)
except Exception as e:
logger.error(f"[API Error] {func.__name__}: {e}")
logger.error(traceback.format_exc())
print(f"[API Error] {func.__name__}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=error_message,
)
return wrapper
return decorator
def _get_handler(status: int, detail: str):
# Define
def handler(request: Request, exception: Exception) -> Response:

BIN
app/home/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -1,4 +1,6 @@
import json
import logging
import traceback
from datetime import date
from pathlib import Path
from typing import Optional
@ -6,6 +8,7 @@ from urllib.parse import unquote, urlparse
import aiofiles
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session, AsyncSessionLocal
@ -23,7 +26,10 @@ from app.home.schemas.home_schema import (
from app.utils.upload_blob_as_request import AzureBlobUploader
from app.utils.chatgpt_prompt import ChatgptService
from app.utils.common import generate_task_id
from app.utils.nvMapScraper import NvMapScraper
from app.utils.nvMapScraper import NvMapScraper, GraphQLException
# 로거 설정
logger = logging.getLogger(__name__)
MEDIA_ROOT = Path("media")
@ -91,15 +97,56 @@ def _extract_region_from_address(road_address: str | None) -> str:
"description": "잘못된 URL",
"model": ErrorResponse,
},
502: {
"description": "크롤링 실패",
"model": ErrorResponse,
},
},
tags=["crawling"],
)
async def crawling(request_body: CrawlingRequest):
"""네이버 지도 장소 크롤링"""
scraper = NvMapScraper(request_body.url)
await scraper.scrap()
import time
request_start = time.perf_counter()
logger.info(f"[crawling] START - url: {request_body.url[:80]}...")
print(f"[crawling] ========== START ==========")
print(f"[crawling] URL: {request_body.url[:80]}...")
# ========== Step 1: 네이버 지도 크롤링 ==========
step1_start = time.perf_counter()
print(f"[crawling] Step 1: 네이버 지도 크롤링 시작...")
try:
scraper = NvMapScraper(request_body.url)
await scraper.scrap()
except GraphQLException as e:
step1_elapsed = (time.perf_counter() - step1_start) * 1000
logger.error(f"[crawling] Step 1 FAILED - GraphQL 크롤링 실패: {e} ({step1_elapsed:.1f}ms)")
print(f"[crawling] Step 1 FAILED - {e} ({step1_elapsed:.1f}ms)")
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"네이버 지도 크롤링에 실패했습니다: {e}",
)
except Exception as e:
step1_elapsed = (time.perf_counter() - step1_start) * 1000
logger.error(f"[crawling] Step 1 FAILED - 크롤링 중 예기치 않은 오류: {e} ({step1_elapsed:.1f}ms)")
print(f"[crawling] Step 1 FAILED - {e} ({step1_elapsed:.1f}ms)")
traceback.print_exc()
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="네이버 지도 크롤링 중 오류가 발생했습니다.",
)
step1_elapsed = (time.perf_counter() - step1_start) * 1000
image_count = len(scraper.image_link_list) if scraper.image_link_list else 0
logger.info(f"[crawling] Step 1 완료 - 이미지 {image_count}개 ({step1_elapsed:.1f}ms)")
print(f"[crawling] Step 1 완료 - 이미지 {image_count}개 ({step1_elapsed:.1f}ms)")
# ========== Step 2: 정보 가공 ==========
step2_start = time.perf_counter()
print(f"[crawling] Step 2: 정보 가공 시작...")
# 가공된 정보 생성
processed_info = None
marketing_analysis = None
@ -114,16 +161,76 @@ async def crawling(request_body: CrawlingRequest):
detail_region_info=road_address or "",
)
# ChatGPT를 이용한 마케팅 분석
chatgpt_service = ChatgptService(
customer_name=customer_name,
region=region,
detail_region_info=road_address or "",
)
prompt = chatgpt_service.build_market_analysis_prompt()
raw_response = await chatgpt_service.generate(prompt)
parsed = await chatgpt_service.parse_marketing_analysis(raw_response)
marketing_analysis = MarketingAnalysis(**parsed)
step2_elapsed = (time.perf_counter() - step2_start) * 1000
logger.info(f"[crawling] Step 2 완료 - {customer_name}, {region} ({step2_elapsed:.1f}ms)")
print(f"[crawling] Step 2 완료 - {customer_name}, {region} ({step2_elapsed:.1f}ms)")
# ========== Step 3: ChatGPT 마케팅 분석 ==========
step3_start = time.perf_counter()
print(f"[crawling] Step 3: ChatGPT 마케팅 분석 시작...")
try:
# Step 3-1: ChatGPT 서비스 초기화
step3_1_start = time.perf_counter()
chatgpt_service = ChatgptService(
customer_name=customer_name,
region=region,
detail_region_info=road_address or "",
)
step3_1_elapsed = (time.perf_counter() - step3_1_start) * 1000
print(f"[crawling] Step 3-1: 서비스 초기화 완료 ({step3_1_elapsed:.1f}ms)")
# Step 3-2: 프롬프트 생성
step3_2_start = time.perf_counter()
prompt = chatgpt_service.build_market_analysis_prompt()
step3_2_elapsed = (time.perf_counter() - step3_2_start) * 1000
print(f"[crawling] Step 3-2: 프롬프트 생성 완료 - {len(prompt)}자 ({step3_2_elapsed:.1f}ms)")
# Step 3-3: GPT API 호출
step3_3_start = time.perf_counter()
raw_response = await chatgpt_service.generate(prompt)
step3_3_elapsed = (time.perf_counter() - step3_3_start) * 1000
logger.info(f"[crawling] Step 3-3: GPT API 호출 완료 - 응답 {len(raw_response)}자 ({step3_3_elapsed:.1f}ms)")
print(f"[crawling] Step 3-3: GPT API 호출 완료 - 응답 {len(raw_response)}자 ({step3_3_elapsed:.1f}ms)")
# Step 3-4: 응답 파싱 (크롤링에서 가져온 facility_info 전달)
step3_4_start = time.perf_counter()
print(f"[crawling] Step 3-4: 응답 파싱 시작 - facility_info: {scraper.facility_info}")
parsed = await chatgpt_service.parse_marketing_analysis(
raw_response, facility_info=scraper.facility_info
)
marketing_analysis = MarketingAnalysis(**parsed)
step3_4_elapsed = (time.perf_counter() - step3_4_start) * 1000
print(f"[crawling] Step 3-4: 응답 파싱 완료 ({step3_4_elapsed:.1f}ms)")
step3_elapsed = (time.perf_counter() - step3_start) * 1000
logger.info(f"[crawling] Step 3 완료 - 마케팅 분석 성공 ({step3_elapsed:.1f}ms)")
print(f"[crawling] Step 3 완료 - 마케팅 분석 성공 ({step3_elapsed:.1f}ms)")
except Exception as e:
step3_elapsed = (time.perf_counter() - step3_start) * 1000
logger.error(f"[crawling] Step 3 FAILED - GPT 마케팅 분석 중 오류: {e} ({step3_elapsed:.1f}ms)")
print(f"[crawling] Step 3 FAILED - {e} ({step3_elapsed:.1f}ms)")
traceback.print_exc()
# GPT 실패 시에도 크롤링 결과는 반환
marketing_analysis = None
else:
step2_elapsed = (time.perf_counter() - step2_start) * 1000
logger.warning(f"[crawling] Step 2 - base_info 없음 ({step2_elapsed:.1f}ms)")
print(f"[crawling] Step 2 - base_info 없음, 마케팅 분석 스킵 ({step2_elapsed:.1f}ms)")
# ========== 완료 ==========
total_elapsed = (time.perf_counter() - request_start) * 1000
logger.info(f"[crawling] COMPLETE - 총 소요시간: {total_elapsed:.1f}ms")
print(f"[crawling] ========== COMPLETE ==========")
print(f"[crawling] 총 소요시간: {total_elapsed:.1f}ms")
print(f"[crawling] - Step 1 (크롤링): {step1_elapsed:.1f}ms")
if scraper.base_info:
print(f"[crawling] - Step 2 (정보가공): {step2_elapsed:.1f}ms")
if 'step3_elapsed' in locals():
print(f"[crawling] - Step 3 (GPT 분석): {step3_elapsed:.1f}ms")
if 'step3_3_elapsed' in locals():
print(f"[crawling] - GPT API 호출: {step3_3_elapsed:.1f}ms")
return {
"image_list": scraper.image_link_list,
@ -274,7 +381,7 @@ async def upload_images(
images_json: Optional[str] = Form(
default=None,
description="외부 이미지 URL 목록 (JSON 문자열)",
example=IMAGES_JSON_EXAMPLE,
examples=[IMAGES_JSON_EXAMPLE],
),
files: Optional[list[UploadFile]] = File(
default=None, description="이미지 바이너리 파일 목록"
@ -492,7 +599,7 @@ async def upload_images_blob(
images_json: Optional[str] = Form(
default=None,
description="외부 이미지 URL 목록 (JSON 문자열)",
example=IMAGES_JSON_EXAMPLE,
examples=[IMAGES_JSON_EXAMPLE],
),
files: Optional[list[UploadFile]] = File(
default=None, description="이미지 바이너리 파일 목록"
@ -666,10 +773,21 @@ async def upload_images_blob(
f"saved: {len(result_images)}, "
f"elapsed: {(stage3_time - stage2_time)*1000:.1f}ms")
except SQLAlchemyError as e:
logger.error(f"[upload_images_blob] DB Error - task_id: {task_id}, error: {e}")
traceback.print_exc()
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="이미지 저장 중 데이터베이스 오류가 발생했습니다.",
)
except Exception as e:
print(f"[upload_images_blob] Stage 3 EXCEPTION - "
logger.error(f"[upload_images_blob] Stage 3 EXCEPTION - "
f"task_id: {task_id}, error: {type(e).__name__}: {e}")
raise
traceback.print_exc()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="이미지 업로드 중 오류가 발생했습니다.",
)
saved_count = len(result_images)
image_urls = [img.img_url for img in result_images]

BIN
app/lyric/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -220,15 +220,23 @@ async def generate_lyric(
session: AsyncSession = Depends(get_session),
) -> GenerateLyricResponse:
"""고객 정보를 기반으로 가사를 생성합니다. (백그라운드 처리)"""
import time
request_start = time.perf_counter()
task_id = request_body.task_id
print(f"[generate_lyric] ========== START ==========")
print(
f"[generate_lyric] START - task_id: {task_id}, "
f"[generate_lyric] task_id: {task_id}, "
f"customer_name: {request_body.customer_name}, "
f"region: {request_body.region}"
)
try:
# 1. ChatGPT 서비스 초기화 및 프롬프트 생성
# ========== Step 1: ChatGPT 서비스 초기화 및 프롬프트 생성 ==========
step1_start = time.perf_counter()
print(f"[generate_lyric] Step 1: 서비스 초기화 및 프롬프트 생성...")
service = ChatgptService(
customer_name=request_body.customer_name,
region=request_body.region,
@ -237,7 +245,13 @@ async def generate_lyric(
)
prompt = service.build_lyrics_prompt()
# 2. Project 테이블에 데이터 저장
step1_elapsed = (time.perf_counter() - step1_start) * 1000
print(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)")
# ========== Step 2: Project 테이블에 데이터 저장 ==========
step2_start = time.perf_counter()
print(f"[generate_lyric] Step 2: Project 저장...")
project = Project(
store_name=request_body.customer_name,
region=request_body.region,
@ -248,12 +262,14 @@ async def generate_lyric(
session.add(project)
await session.commit()
await session.refresh(project)
print(
f"[generate_lyric] Project saved - "
f"project_id: {project.id}, task_id: {task_id}"
)
# 3. Lyric 테이블에 데이터 저장 (status: processing)
step2_elapsed = (time.perf_counter() - step2_start) * 1000
print(f"[generate_lyric] Step 2 완료 - project_id: {project.id} ({step2_elapsed:.1f}ms)")
# ========== Step 3: Lyric 테이블에 데이터 저장 ==========
step3_start = time.perf_counter()
print(f"[generate_lyric] Step 3: Lyric 저장 (processing)...")
lyric = Lyric(
project_id=project.id,
task_id=task_id,
@ -265,19 +281,33 @@ async def generate_lyric(
session.add(lyric)
await session.commit()
await session.refresh(lyric)
print(
f"[generate_lyric] Lyric saved (processing) - "
f"lyric_id: {lyric.id}, task_id: {task_id}"
)
# 4. 백그라운드 태스크로 ChatGPT 가사 생성 실행
step3_elapsed = (time.perf_counter() - step3_start) * 1000
print(f"[generate_lyric] Step 3 완료 - lyric_id: {lyric.id} ({step3_elapsed:.1f}ms)")
# ========== Step 4: 백그라운드 태스크 스케줄링 ==========
step4_start = time.perf_counter()
print(f"[generate_lyric] Step 4: 백그라운드 태스크 스케줄링...")
background_tasks.add_task(
generate_lyric_background,
task_id=task_id,
prompt=prompt,
language=request_body.language,
)
print(f"[generate_lyric] Background task scheduled - task_id: {task_id}")
step4_elapsed = (time.perf_counter() - step4_start) * 1000
print(f"[generate_lyric] Step 4 완료 ({step4_elapsed:.1f}ms)")
# ========== 완료 ==========
total_elapsed = (time.perf_counter() - request_start) * 1000
print(f"[generate_lyric] ========== COMPLETE ==========")
print(f"[generate_lyric] API 응답 소요시간: {total_elapsed:.1f}ms")
print(f"[generate_lyric] - Step 1 (프롬프트 생성): {step1_elapsed:.1f}ms")
print(f"[generate_lyric] - Step 2 (Project 저장): {step2_elapsed:.1f}ms")
print(f"[generate_lyric] - Step 3 (Lyric 저장): {step3_elapsed:.1f}ms")
print(f"[generate_lyric] - Step 4 (태스크 스케줄링): {step4_elapsed:.1f}ms")
print(f"[generate_lyric] (GPT API 호출은 백그라운드에서 별도 진행)")
# 5. 즉시 응답 반환
return GenerateLyricResponse(
@ -289,7 +319,8 @@ async def generate_lyric(
)
except Exception as e:
print(f"[generate_lyric] EXCEPTION - task_id: {task_id}, error: {e}")
elapsed = (time.perf_counter() - request_start) * 1000
print(f"[generate_lyric] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)")
await session.rollback()
return GenerateLyricResponse(
success=False,

View File

@ -52,7 +52,7 @@ class SongFormData:
attributes: Dict[str, str] = field(default_factory=dict)
attributes_str: str = ""
lyrics_ids: List[int] = field(default_factory=list)
llm_model: str = "gpt-4o"
llm_model: str = "gpt-5-mini"
@classmethod
async def from_form(cls, request: Request):
@ -86,6 +86,6 @@ class SongFormData:
attributes=attributes,
attributes_str=attributes_str,
lyrics_ids=lyrics_ids,
llm_model=form_data.get("llm_model", "gpt-4o"),
llm_model=form_data.get("llm_model", "gpt-5-mini"),
prompts=form_data.get("prompts", ""),
)

View File

@ -783,7 +783,7 @@ async def make_automation(request: Request, conn: Connection):
else "",
"attr_value": ", ".join(selected_values) if selected_values else "",
"ai": "ChatGPT",
"ai_model": "gpt-4o",
"ai_model": "gpt-5-mini",
"genre": "후크송",
"sample_song": combined_sample_song or "없음",
"result_song": final_lyrics,

View File

@ -4,12 +4,67 @@ Lyric Background Tasks
가사 생성 관련 백그라운드 태스크를 정의합니다.
"""
import logging
import traceback
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from app.database.session import BackgroundSessionLocal
from app.lyric.models import Lyric
from app.utils.chatgpt_prompt import ChatgptService
# 로거 설정
logger = logging.getLogger(__name__)
async def _update_lyric_status(
task_id: str,
status: str,
result: str | None = None,
) -> bool:
"""Lyric 테이블의 상태를 업데이트합니다.
Args:
task_id: 프로젝트 task_id
status: 변경할 상태 ("processing", "completed", "failed")
result: 가사 결과 또는 에러 메시지
Returns:
bool: 업데이트 성공 여부
"""
try:
async with BackgroundSessionLocal() as session:
query_result = await session.execute(
select(Lyric)
.where(Lyric.task_id == task_id)
.order_by(Lyric.created_at.desc())
.limit(1)
)
lyric = query_result.scalar_one_or_none()
if lyric:
lyric.status = status
if result is not None:
lyric.lyric_result = result
await session.commit()
logger.info(f"[Lyric] Status updated - task_id: {task_id}, status: {status}")
print(f"[Lyric] Status updated - task_id: {task_id}, status: {status}")
return True
else:
logger.warning(f"[Lyric] NOT FOUND in DB - task_id: {task_id}")
print(f"[Lyric] NOT FOUND in DB - task_id: {task_id}")
return False
except SQLAlchemyError as e:
logger.error(f"[Lyric] DB Error while updating status - task_id: {task_id}, error: {e}")
print(f"[Lyric] DB Error while updating status - task_id: {task_id}, error: {e}")
return False
except Exception as e:
logger.error(f"[Lyric] Unexpected error while updating status - task_id: {task_id}, error: {e}")
print(f"[Lyric] Unexpected error while updating status - task_id: {task_id}, error: {e}")
return False
async def generate_lyric_background(
task_id: str,
@ -23,10 +78,20 @@ async def generate_lyric_background(
prompt: ChatGPT에 전달할 프롬프트
language: 가사 언어
"""
print(f"[generate_lyric_background] START - task_id: {task_id}")
import time
task_start = time.perf_counter()
logger.info(f"[generate_lyric_background] START - task_id: {task_id}")
print(f"[generate_lyric_background] ========== START ==========")
print(f"[generate_lyric_background] task_id: {task_id}")
print(f"[generate_lyric_background] language: {language}")
print(f"[generate_lyric_background] prompt length: {len(prompt)}")
try:
# ChatGPT 서비스 초기화 (프롬프트는 이미 생성되어 있음)
# ========== Step 1: ChatGPT 서비스 초기화 ==========
step1_start = time.perf_counter()
print(f"[generate_lyric_background] Step 1: ChatGPT 서비스 초기화...")
service = ChatgptService(
customer_name="", # 프롬프트가 이미 생성되었으므로 빈 값
region="",
@ -34,65 +99,48 @@ async def generate_lyric_background(
language=language,
)
# ChatGPT를 통해 가사 생성
print(f"[generate_lyric_background] ChatGPT generation started - task_id: {task_id}")
step1_elapsed = (time.perf_counter() - step1_start) * 1000
print(f"[generate_lyric_background] Step 1 완료 ({step1_elapsed:.1f}ms)")
# ========== Step 2: ChatGPT API 호출 (가사 생성) ==========
step2_start = time.perf_counter()
logger.info(f"[generate_lyric_background] Step 2: ChatGPT API 호출 시작 - task_id: {task_id}")
print(f"[generate_lyric_background] Step 2: ChatGPT API 호출 시작...")
result = await service.generate(prompt=prompt)
print(f"[generate_lyric_background] ChatGPT generation completed - task_id: {task_id}")
# 실패 응답 검사 (ERROR 또는 ChatGPT 거부 응답)
failure_patterns = [
"ERROR:",
"I'm sorry",
"I cannot",
"I can't",
"I apologize",
"I'm unable",
"I am unable",
"I'm not able",
"I am not able",
]
is_failure = any(
pattern.lower() in result.lower() for pattern in failure_patterns
)
step2_elapsed = (time.perf_counter() - step2_start) * 1000
logger.info(f"[generate_lyric_background] Step 2 완료 - 응답 {len(result)}자 ({step2_elapsed:.1f}ms)")
print(f"[generate_lyric_background] Step 2 완료 - 응답 {len(result)}자 ({step2_elapsed:.1f}ms)")
# Lyric 테이블 업데이트 (백그라운드 전용 세션 사용)
async with BackgroundSessionLocal() as session:
query_result = await session.execute(
select(Lyric)
.where(Lyric.task_id == task_id)
.order_by(Lyric.created_at.desc())
.limit(1)
)
lyric = query_result.scalar_one_or_none()
# ========== Step 3: DB 상태 업데이트 ==========
step3_start = time.perf_counter()
print(f"[generate_lyric_background] Step 3: DB 상태 업데이트...")
if lyric:
if is_failure:
print(f"[generate_lyric_background] FAILED - task_id: {task_id}, error: {result}")
lyric.status = "failed"
lyric.lyric_result = result
else:
print(f"[generate_lyric_background] SUCCESS - task_id: {task_id}")
lyric.status = "completed"
lyric.lyric_result = result
await _update_lyric_status(task_id, "completed", result)
await session.commit()
else:
print(f"[generate_lyric_background] Lyric NOT FOUND in DB - task_id: {task_id}")
step3_elapsed = (time.perf_counter() - step3_start) * 1000
print(f"[generate_lyric_background] Step 3 완료 ({step3_elapsed:.1f}ms)")
# ========== 완료 ==========
total_elapsed = (time.perf_counter() - task_start) * 1000
logger.info(f"[generate_lyric_background] SUCCESS - task_id: {task_id}, 총 소요시간: {total_elapsed:.1f}ms")
print(f"[generate_lyric_background] ========== SUCCESS ==========")
print(f"[generate_lyric_background] 총 소요시간: {total_elapsed:.1f}ms")
print(f"[generate_lyric_background] - Step 1 (서비스 초기화): {step1_elapsed:.1f}ms")
print(f"[generate_lyric_background] - Step 2 (GPT API 호출): {step2_elapsed:.1f}ms")
print(f"[generate_lyric_background] - Step 3 (DB 업데이트): {step3_elapsed:.1f}ms")
except SQLAlchemyError as e:
elapsed = (time.perf_counter() - task_start) * 1000
logger.error(f"[generate_lyric_background] DB ERROR - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)")
print(f"[generate_lyric_background] DB ERROR - {e} ({elapsed:.1f}ms)")
traceback.print_exc()
await _update_lyric_status(task_id, "failed", f"Database Error: {str(e)}")
except Exception as e:
print(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e}")
# 실패 시 Lyric 테이블 업데이트
async with BackgroundSessionLocal() as session:
query_result = await session.execute(
select(Lyric)
.where(Lyric.task_id == task_id)
.order_by(Lyric.created_at.desc())
.limit(1)
)
lyric = query_result.scalar_one_or_none()
if lyric:
lyric.status = "failed"
lyric.lyric_result = f"Error: {str(e)}"
await session.commit()
print(f"[generate_lyric_background] FAILED - task_id: {task_id}, status updated to failed")
elapsed = (time.perf_counter() - task_start) * 1000
logger.error(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)")
print(f"[generate_lyric_background] EXCEPTION - {e} ({elapsed:.1f}ms)")
traceback.print_exc()
await _update_lyric_status(task_id, "failed", f"Error: {str(e)}")

BIN
app/song/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -335,7 +335,7 @@ class SongFormData:
attributes: Dict[str, str] = field(default_factory=dict)
attributes_str: str = ""
lyrics_ids: List[int] = field(default_factory=list)
llm_model: str = "gpt-4o"
llm_model: str = "gpt-5-mini"
@classmethod
async def from_form(cls, request: Request):
@ -369,6 +369,6 @@ class SongFormData:
attributes=attributes,
attributes_str=attributes_str,
lyrics_ids=lyrics_ids,
llm_model=form_data.get("llm_model", "gpt-4o"),
llm_model=form_data.get("llm_model", "gpt-5-mini"),
prompts=form_data.get("prompts", ""),
)

View File

@ -783,7 +783,7 @@ async def make_automation(request: Request, conn: Connection):
else "",
"attr_value": ", ".join(selected_values) if selected_values else "",
"ai": "ChatGPT",
"ai_model": "gpt-4o",
"ai_model": "gpt-5-mini",
"genre": "후크송",
"sample_song": combined_sample_song or "없음",
"result_song": final_lyrics,

View File

@ -4,12 +4,15 @@ Song Background Tasks
노래 생성 관련 백그라운드 태스크를 정의합니다.
"""
import logging
import traceback
from datetime import date
from pathlib import Path
import aiofiles
import httpx
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from app.database.session import BackgroundSessionLocal
from app.song.models import Song
@ -17,6 +20,100 @@ from app.utils.common import generate_task_id
from app.utils.upload_blob_as_request import AzureBlobUploader
from config import prj_settings
# 로거 설정
logger = logging.getLogger(__name__)
# HTTP 요청 설정
REQUEST_TIMEOUT = 120.0 # 초
async def _update_song_status(
task_id: str,
status: str,
song_url: str | None = None,
suno_task_id: str | None = None,
duration: float | None = None,
) -> bool:
"""Song 테이블의 상태를 업데이트합니다.
Args:
task_id: 프로젝트 task_id
status: 변경할 상태 ("processing", "completed", "failed")
song_url: 노래 URL
suno_task_id: Suno task ID (선택)
duration: 노래 길이 (선택)
Returns:
bool: 업데이트 성공 여부
"""
try:
async with BackgroundSessionLocal() as session:
if suno_task_id:
query_result = await session.execute(
select(Song)
.where(Song.suno_task_id == suno_task_id)
.order_by(Song.created_at.desc())
.limit(1)
)
else:
query_result = await session.execute(
select(Song)
.where(Song.task_id == task_id)
.order_by(Song.created_at.desc())
.limit(1)
)
song = query_result.scalar_one_or_none()
if song:
song.status = status
if song_url is not None:
song.song_result_url = song_url
if duration is not None:
song.duration = duration
await session.commit()
logger.info(f"[Song] Status updated - task_id: {task_id}, status: {status}")
print(f"[Song] Status updated - task_id: {task_id}, status: {status}")
return True
else:
logger.warning(f"[Song] NOT FOUND in DB - task_id: {task_id}")
print(f"[Song] NOT FOUND in DB - task_id: {task_id}")
return False
except SQLAlchemyError as e:
logger.error(f"[Song] DB Error while updating status - task_id: {task_id}, error: {e}")
print(f"[Song] DB Error while updating status - task_id: {task_id}, error: {e}")
return False
except Exception as e:
logger.error(f"[Song] Unexpected error while updating status - task_id: {task_id}, error: {e}")
print(f"[Song] Unexpected error while updating status - task_id: {task_id}, error: {e}")
return False
async def _download_audio(url: str, task_id: str) -> bytes:
"""URL에서 오디오 파일을 다운로드합니다.
Args:
url: 다운로드할 URL
task_id: 로그용 task_id
Returns:
bytes: 다운로드한 파일 내용
Raises:
httpx.HTTPError: 다운로드 실패
"""
logger.info(f"[Download] Downloading - task_id: {task_id}")
print(f"[Download] Downloading - task_id: {task_id}")
async with httpx.AsyncClient() as client:
response = await client.get(url, timeout=REQUEST_TIMEOUT)
response.raise_for_status()
logger.info(f"[Download] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes")
print(f"[Download] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes")
return response.content
async def download_and_save_song(
task_id: str,
@ -30,7 +127,9 @@ async def download_and_save_song(
audio_url: 다운로드할 오디오 URL
store_name: 저장할 파일명에 사용할 업체명
"""
logger.info(f"[download_and_save_song] START - task_id: {task_id}, store_name: {store_name}")
print(f"[download_and_save_song] START - task_id: {task_id}, store_name: {store_name}")
try:
# 저장 경로 생성: media/song/{날짜}/{uuid7}/{store_name}.mp3
today = date.today().strftime("%Y-%m-%d")
@ -46,60 +145,50 @@ async def download_and_save_song(
media_dir = Path("media") / "song" / today / unique_id
media_dir.mkdir(parents=True, exist_ok=True)
file_path = media_dir / file_name
logger.info(f"[download_and_save_song] Directory created - path: {file_path}")
print(f"[download_and_save_song] Directory created - path: {file_path}")
# 오디오 파일 다운로드
logger.info(f"[download_and_save_song] Downloading audio - task_id: {task_id}, url: {audio_url}")
print(f"[download_and_save_song] Downloading audio - task_id: {task_id}, url: {audio_url}")
async with httpx.AsyncClient() as client:
response = await client.get(audio_url, timeout=60.0)
response.raise_for_status()
async with aiofiles.open(str(file_path), "wb") as f:
await f.write(response.content)
content = await _download_audio(audio_url, task_id)
async with aiofiles.open(str(file_path), "wb") as f:
await f.write(content)
logger.info(f"[download_and_save_song] File saved - task_id: {task_id}, path: {file_path}")
print(f"[download_and_save_song] File saved - task_id: {task_id}, path: {file_path}")
# 프론트엔드에서 접근 가능한 URL 생성
relative_path = f"/media/song/{today}/{unique_id}/{file_name}"
base_url = f"http://{prj_settings.PROJECT_DOMAIN}"
file_url = f"{base_url}{relative_path}"
logger.info(f"[download_and_save_song] URL generated - task_id: {task_id}, url: {file_url}")
print(f"[download_and_save_song] URL generated - task_id: {task_id}, url: {file_url}")
# Song 테이블 업데이트 (새 세션 사용)
async with BackgroundSessionLocal() as session:
# 여러 개 있을 경우 가장 최근 것 선택
result = await session.execute(
select(Song)
.where(Song.task_id == task_id)
.order_by(Song.created_at.desc())
.limit(1)
)
song = result.scalar_one_or_none()
# Song 테이블 업데이트
await _update_song_status(task_id, "completed", file_url)
logger.info(f"[download_and_save_song] SUCCESS - task_id: {task_id}")
print(f"[download_and_save_song] SUCCESS - task_id: {task_id}")
if song:
song.status = "completed"
song.song_result_url = file_url
await session.commit()
print(f"[download_and_save_song] SUCCESS - task_id: {task_id}, status: completed")
else:
print(f"[download_and_save_song] Song NOT FOUND in DB - task_id: {task_id}")
except httpx.HTTPError as e:
logger.error(f"[download_and_save_song] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
print(f"[download_and_save_song] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
traceback.print_exc()
await _update_song_status(task_id, "failed")
except SQLAlchemyError as e:
logger.error(f"[download_and_save_song] DB ERROR - task_id: {task_id}, error: {e}")
print(f"[download_and_save_song] DB ERROR - task_id: {task_id}, error: {e}")
traceback.print_exc()
await _update_song_status(task_id, "failed")
except Exception as e:
logger.error(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}")
print(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}")
# 실패 시 Song 테이블 업데이트
async with BackgroundSessionLocal() as session:
# 여러 개 있을 경우 가장 최근 것 선택
result = await session.execute(
select(Song)
.where(Song.task_id == task_id)
.order_by(Song.created_at.desc())
.limit(1)
)
song = result.scalar_one_or_none()
if song:
song.status = "failed"
await session.commit()
print(f"[download_and_save_song] FAILED - task_id: {task_id}, status updated to failed")
traceback.print_exc()
await _update_song_status(task_id, "failed")
async def download_and_upload_song_to_blob(
@ -114,6 +203,7 @@ async def download_and_upload_song_to_blob(
audio_url: 다운로드할 오디오 URL
store_name: 저장할 파일명에 사용할 업체명
"""
logger.info(f"[download_and_upload_song_to_blob] START - task_id: {task_id}, store_name: {store_name}")
print(f"[download_and_upload_song_to_blob] START - task_id: {task_id}, store_name: {store_name}")
temp_file_path: Path | None = None
@ -129,16 +219,19 @@ async def download_and_upload_song_to_blob(
temp_dir = Path("media") / "temp" / task_id
temp_dir.mkdir(parents=True, exist_ok=True)
temp_file_path = temp_dir / file_name
logger.info(f"[download_and_upload_song_to_blob] Temp directory created - path: {temp_file_path}")
print(f"[download_and_upload_song_to_blob] Temp directory created - path: {temp_file_path}")
# 오디오 파일 다운로드
logger.info(f"[download_and_upload_song_to_blob] Downloading audio - task_id: {task_id}, url: {audio_url}")
print(f"[download_and_upload_song_to_blob] Downloading audio - task_id: {task_id}, url: {audio_url}")
async with httpx.AsyncClient() as client:
response = await client.get(audio_url, timeout=60.0)
response.raise_for_status()
async with aiofiles.open(str(temp_file_path), "wb") as f:
await f.write(response.content)
content = await _download_audio(audio_url, task_id)
async with aiofiles.open(str(temp_file_path), "wb") as f:
await f.write(content)
logger.info(f"[download_and_upload_song_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}")
print(f"[download_and_upload_song_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}")
# Azure Blob Storage에 업로드
@ -150,51 +243,41 @@ async def download_and_upload_song_to_blob(
# SAS 토큰이 제외된 public_url 사용
blob_url = uploader.public_url
logger.info(f"[download_and_upload_song_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
print(f"[download_and_upload_song_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
# Song 테이블 업데이트 (새 세션 사용)
async with BackgroundSessionLocal() as session:
# 여러 개 있을 경우 가장 최근 것 선택
result = await session.execute(
select(Song)
.where(Song.task_id == task_id)
.order_by(Song.created_at.desc())
.limit(1)
)
song = result.scalar_one_or_none()
# Song 테이블 업데이트
await _update_song_status(task_id, "completed", blob_url)
logger.info(f"[download_and_upload_song_to_blob] SUCCESS - task_id: {task_id}")
print(f"[download_and_upload_song_to_blob] SUCCESS - task_id: {task_id}")
if song:
song.status = "completed"
song.song_result_url = blob_url
await session.commit()
print(f"[download_and_upload_song_to_blob] SUCCESS - task_id: {task_id}, status: completed")
else:
print(f"[download_and_upload_song_to_blob] Song NOT FOUND in DB - task_id: {task_id}")
except httpx.HTTPError as e:
logger.error(f"[download_and_upload_song_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
print(f"[download_and_upload_song_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
traceback.print_exc()
await _update_song_status(task_id, "failed")
except SQLAlchemyError as e:
logger.error(f"[download_and_upload_song_to_blob] DB ERROR - task_id: {task_id}, error: {e}")
print(f"[download_and_upload_song_to_blob] DB ERROR - task_id: {task_id}, error: {e}")
traceback.print_exc()
await _update_song_status(task_id, "failed")
except Exception as e:
logger.error(f"[download_and_upload_song_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
print(f"[download_and_upload_song_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
# 실패 시 Song 테이블 업데이트
async with BackgroundSessionLocal() as session:
result = await session.execute(
select(Song)
.where(Song.task_id == task_id)
.order_by(Song.created_at.desc())
.limit(1)
)
song = result.scalar_one_or_none()
if song:
song.status = "failed"
await session.commit()
print(f"[download_and_upload_song_to_blob] FAILED - task_id: {task_id}, status updated to failed")
traceback.print_exc()
await _update_song_status(task_id, "failed")
finally:
# 임시 파일 삭제
if temp_file_path and temp_file_path.exists():
try:
temp_file_path.unlink()
logger.info(f"[download_and_upload_song_to_blob] Temp file deleted - path: {temp_file_path}")
print(f"[download_and_upload_song_to_blob] Temp file deleted - path: {temp_file_path}")
except Exception as e:
logger.warning(f"[download_and_upload_song_to_blob] Failed to delete temp file: {e}")
print(f"[download_and_upload_song_to_blob] Failed to delete temp file: {e}")
# 임시 디렉토리 삭제 시도
@ -220,6 +303,7 @@ async def download_and_upload_song_by_suno_task_id(
store_name: 저장할 파일명에 사용할 업체명
duration: 노래 재생 시간 ()
"""
logger.info(f"[download_and_upload_song_by_suno_task_id] START - suno_task_id: {suno_task_id}, store_name: {store_name}, duration: {duration}")
print(f"[download_and_upload_song_by_suno_task_id] START - suno_task_id: {suno_task_id}, store_name: {store_name}, duration: {duration}")
temp_file_path: Path | None = None
task_id: str | None = None
@ -236,10 +320,12 @@ async def download_and_upload_song_by_suno_task_id(
song = result.scalar_one_or_none()
if not song:
logger.warning(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND - suno_task_id: {suno_task_id}")
print(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND - suno_task_id: {suno_task_id}")
return
task_id = song.task_id
logger.info(f"[download_and_upload_song_by_suno_task_id] Song found - suno_task_id: {suno_task_id}, task_id: {task_id}")
print(f"[download_and_upload_song_by_suno_task_id] Song found - suno_task_id: {suno_task_id}, task_id: {task_id}")
# 파일명에 사용할 수 없는 문자 제거
@ -253,16 +339,19 @@ async def download_and_upload_song_by_suno_task_id(
temp_dir = Path("media") / "temp" / task_id
temp_dir.mkdir(parents=True, exist_ok=True)
temp_file_path = temp_dir / file_name
logger.info(f"[download_and_upload_song_by_suno_task_id] Temp directory created - path: {temp_file_path}")
print(f"[download_and_upload_song_by_suno_task_id] Temp directory created - path: {temp_file_path}")
# 오디오 파일 다운로드
logger.info(f"[download_and_upload_song_by_suno_task_id] Downloading audio - suno_task_id: {suno_task_id}, url: {audio_url}")
print(f"[download_and_upload_song_by_suno_task_id] Downloading audio - suno_task_id: {suno_task_id}, url: {audio_url}")
async with httpx.AsyncClient() as client:
response = await client.get(audio_url, timeout=60.0)
response.raise_for_status()
async with aiofiles.open(str(temp_file_path), "wb") as f:
await f.write(response.content)
content = await _download_audio(audio_url, task_id)
async with aiofiles.open(str(temp_file_path), "wb") as f:
await f.write(content)
logger.info(f"[download_and_upload_song_by_suno_task_id] File downloaded - suno_task_id: {suno_task_id}, path: {temp_file_path}")
print(f"[download_and_upload_song_by_suno_task_id] File downloaded - suno_task_id: {suno_task_id}, path: {temp_file_path}")
# Azure Blob Storage에 업로드
@ -274,53 +363,50 @@ async def download_and_upload_song_by_suno_task_id(
# SAS 토큰이 제외된 public_url 사용
blob_url = uploader.public_url
logger.info(f"[download_and_upload_song_by_suno_task_id] Uploaded to Blob - suno_task_id: {suno_task_id}, url: {blob_url}")
print(f"[download_and_upload_song_by_suno_task_id] Uploaded to Blob - suno_task_id: {suno_task_id}, url: {blob_url}")
# Song 테이블 업데이트 (새 세션 사용)
async with BackgroundSessionLocal() as session:
result = await session.execute(
select(Song)
.where(Song.suno_task_id == suno_task_id)
.order_by(Song.created_at.desc())
.limit(1)
)
song = result.scalar_one_or_none()
# Song 테이블 업데이트
await _update_song_status(
task_id=task_id,
status="completed",
song_url=blob_url,
suno_task_id=suno_task_id,
duration=duration,
)
logger.info(f"[download_and_upload_song_by_suno_task_id] SUCCESS - suno_task_id: {suno_task_id}, duration: {duration}")
print(f"[download_and_upload_song_by_suno_task_id] SUCCESS - suno_task_id: {suno_task_id}, duration: {duration}")
if song:
song.status = "completed"
song.song_result_url = blob_url
if duration is not None:
song.duration = duration
await session.commit()
print(f"[download_and_upload_song_by_suno_task_id] SUCCESS - suno_task_id: {suno_task_id}, status: completed, duration: {duration}")
else:
print(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND in DB - suno_task_id: {suno_task_id}")
except httpx.HTTPError as e:
logger.error(f"[download_and_upload_song_by_suno_task_id] DOWNLOAD ERROR - suno_task_id: {suno_task_id}, error: {e}")
print(f"[download_and_upload_song_by_suno_task_id] DOWNLOAD ERROR - suno_task_id: {suno_task_id}, error: {e}")
traceback.print_exc()
if task_id:
await _update_song_status(task_id, "failed", suno_task_id=suno_task_id)
except SQLAlchemyError as e:
logger.error(f"[download_and_upload_song_by_suno_task_id] DB ERROR - suno_task_id: {suno_task_id}, error: {e}")
print(f"[download_and_upload_song_by_suno_task_id] DB ERROR - suno_task_id: {suno_task_id}, error: {e}")
traceback.print_exc()
if task_id:
await _update_song_status(task_id, "failed", suno_task_id=suno_task_id)
except Exception as e:
logger.error(f"[download_and_upload_song_by_suno_task_id] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}")
print(f"[download_and_upload_song_by_suno_task_id] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}")
# 실패 시 Song 테이블 업데이트
traceback.print_exc()
if task_id:
async with BackgroundSessionLocal() as session:
result = await session.execute(
select(Song)
.where(Song.suno_task_id == suno_task_id)
.order_by(Song.created_at.desc())
.limit(1)
)
song = result.scalar_one_or_none()
if song:
song.status = "failed"
await session.commit()
print(f"[download_and_upload_song_by_suno_task_id] FAILED - suno_task_id: {suno_task_id}, status updated to failed")
await _update_song_status(task_id, "failed", suno_task_id=suno_task_id)
finally:
# 임시 파일 삭제
if temp_file_path and temp_file_path.exists():
try:
temp_file_path.unlink()
logger.info(f"[download_and_upload_song_by_suno_task_id] Temp file deleted - path: {temp_file_path}")
print(f"[download_and_upload_song_by_suno_task_id] Temp file deleted - path: {temp_file_path}")
except Exception as e:
logger.warning(f"[download_and_upload_song_by_suno_task_id] Failed to delete temp file: {e}")
print(f"[download_and_upload_song_by_suno_task_id] Failed to delete temp file: {e}")
# 임시 디렉토리 삭제 시도

View File

@ -1,10 +1,14 @@
import json
import logging
import re
from openai import AsyncOpenAI
from config import apikey_settings
# 로거 설정
logger = logging.getLogger(__name__)
# fmt: off
LYRICS_PROMPT_TEMPLATE_ORI = """
1.Act as a content marketing expert with domain knowledges in [pension/staying services] in Korea, Goal: plan viral content creation that lead online reservations and promotion
@ -156,18 +160,10 @@ Provide comprehensive marketing analysis including:
- Return as JSON with key "tags"
- **MUST be written in Korean (한국어)**
2. Facilities
- Based on the business name and region details, identify 5 likely facilities/amenities
- Consider typical facilities for accommodations in the given region
- Examples: 바베큐장, 수영장, 주차장, 와이파이, 주방, 테라스, 정원, etc.
- Return as JSON with key "facilities"
- **MUST be written in Korean (한국어)**
[CRITICAL LANGUAGE REQUIREMENT - ABSOLUTE RULE]
ALL OUTPUT MUST BE WRITTEN IN KOREAN (한국어)
- Analysis sections: Korean only
- Tags: Korean only
- Facilities: Korean only
- This is a NON-NEGOTIABLE requirement
- Any output in English or other languages is considered a FAILURE
- Violation of this rule invalidates the entire response
@ -199,8 +195,7 @@ ALL OUTPUT MUST BE WRITTEN IN KOREAN (한국어)
## JSON Data
```json
{{
"tags": ["태그1", "태그2", "태그3", "태그4", "태그5"],
"facilities": ["부대시설1", "부대시설2", "부대시설3", "부대시설4", "부대시설5"]
"tags": ["태그1", "태그2", "태그3", "태그4", "태그5"]
}}
```
---
@ -215,6 +210,11 @@ ERROR: [Brief reason for failure in English]
class ChatgptService:
"""ChatGPT API 서비스 클래스
GPT 5.0 모델을 사용하여 마케팅 가사 분석을 생성합니다.
"""
def __init__(
self,
customer_name: str,
@ -222,9 +222,8 @@ class ChatgptService:
detail_region_info: str = "",
language: str = "Korean",
):
# 최신 모델: GPT-5, GPT-5 mini, GPT-5 nano, GPT-4.1, GPT-4.1 mini, GPT-4.1 nano
# 이전 세대: GPT-4o, GPT-4o mini, GPT-4 Turbo, GPT-3.5 Turbo
self.model = "gpt-4o"
# 최신 모델: gpt-5-mini
self.model = "gpt-5-mini"
self.client = AsyncOpenAI(api_key=apikey_settings.CHATGPT_API_KEY)
self.customer_name = customer_name
self.region = region
@ -248,44 +247,102 @@ class ChatgptService:
detail_region_info=self.detail_region_info,
)
async def generate(self, prompt: str | None = None) -> str:
"""GPT에게 프롬프트를 전달하여 결과를 반환"""
if prompt is None:
prompt = self.build_lyrics_prompt()
print("Generated Prompt: ", prompt)
async def _call_gpt_api(self, prompt: str) -> str:
"""GPT API를 직접 호출합니다 (내부 메서드).
Args:
prompt: GPT에 전달할 프롬프트
Returns:
GPT 응답 문자열
Raises:
APIError, APIConnectionError, RateLimitError: OpenAI API 오류
"""
completion = await self.client.chat.completions.create(
model=self.model, messages=[{"role": "user", "content": prompt}]
)
message = completion.choices[0].message.content
return message or ""
async def generate(
self,
prompt: str | None = None,
) -> str:
"""GPT에게 프롬프트를 전달하여 결과를 반환합니다.
Args:
prompt: GPT에 전달할 프롬프트 (None이면 기본 가사 프롬프트 사용)
Returns:
GPT 응답 문자열
Raises:
APIError, APIConnectionError, RateLimitError: OpenAI API 오류
"""
if prompt is None:
prompt = self.build_lyrics_prompt()
print(f"[ChatgptService] Generated Prompt (length: {len(prompt)})")
logger.info(f"[ChatgptService] Starting GPT request with model: {self.model}")
# GPT API 호출
response = await self._call_gpt_api(prompt)
print(f"[ChatgptService] SUCCESS - Response length: {len(response)}")
logger.info(f"[ChatgptService] SUCCESS - Response length: {len(response)}")
return response
async def summarize_marketing(self, text: str) -> str:
"""마케팅 텍스트를 항목으로 구분하여 500자로 요약 정리"""
"""마케팅 텍스트를 항목으로 구분하여 500자로 요약 정리.
Args:
text: 요약할 마케팅 텍스트
Returns:
요약된 텍스트
Raises:
APIError, APIConnectionError, RateLimitError: OpenAI API 오류
"""
prompt = f"""[ROLE]
마케팅 콘텐츠 요약 전문가
마케팅 콘텐츠 요약 전문가
[INPUT]
{text}
[INPUT]
{text}
[TASK]
텍스트를 분석하여 핵심 내용을 항목별로 구분하여 500 이내로 요약해주세요.
[TASK]
텍스트를 분석하여 핵심 내용을 항목별로 구분하여 500 이내로 요약해주세요.
[OUTPUT REQUIREMENTS]
- 항목별로 구분하여 정리 (: 타겟 고객, 차별점, 지역 특성 )
- 500 이내로 요약
- 핵심 정보만 간결하게 포함
- 한국어로 작성
[OUTPUT REQUIREMENTS]
- 5 항목으로 구분: 타겟 고객, 핵심 차별점, 지역 특성, 시즌별 포인트, 추천 키워드
- 항목은 줄바꿈으로 구분
- 500 이내로 요약
- 핵심 정보만 간결하게 포함
- 한국어로 작성
- 특수문자 사용 금지 (괄호, 슬래시, 하이픈, 물결표 제외)
- 쉼표와 마침표만 사용하여 자연스러운 문장으로 작성
[OUTPUT FORMAT]
---
[항목별로 구분된 500 이내 요약]
---
"""
completion = await self.client.chat.completions.create(
model=self.model, messages=[{"role": "user", "content": prompt}]
)
message = completion.choices[0].message.content
result = message or ""
[OUTPUT FORMAT - 반드시 아래 형식 준수]
---
타겟 고객
[대상 고객층을 자연스러운 문장으로 설명]
핵심 차별점
[숙소의 차별화 포인트를 자연스러운 문장으로 설명]
지역 특성
[주변 관광지와 지역 특색을 자연스러운 문장으로 설명]
시즌별 포인트
[계절별 매력 포인트를 자연스러운 문장으로 설명]
추천 키워드
[마케팅에 활용할 키워드를 쉼표로 구분하여 나열]
---
"""
result = await self.generate(prompt=prompt)
# --- 구분자 제거
if result.startswith("---"):
@ -295,9 +352,15 @@ class ChatgptService:
return result
async def parse_marketing_analysis(self, raw_response: str) -> dict:
async def parse_marketing_analysis(
self, raw_response: str, facility_info: str | None = None
) -> dict:
"""ChatGPT 마케팅 분석 응답을 파싱하고 요약하여 딕셔너리로 반환
Args:
raw_response: ChatGPT 마케팅 분석 응답 원문
facility_info: 크롤링에서 가져온 편의시설 정보 문자열
Returns:
dict: {"report": str, "tags": list[str], "facilities": list[str]}
"""
@ -311,7 +374,7 @@ class ChatgptService:
try:
json_data = json.loads(json_match.group(1))
tags = json_data.get("tags", [])
facilities = json_data.get("facilities", [])
print(f"[parse_marketing_analysis] GPT 응답에서 tags 파싱 완료: {tags}")
# JSON 블록을 제외한 리포트 부분 추출
report = raw_response[: json_match.start()].strip()
# --- 구분자 제거
@ -320,10 +383,22 @@ class ChatgptService:
if report.endswith("---"):
report = report[:-3].strip()
except json.JSONDecodeError:
print("[parse_marketing_analysis] JSON 파싱 실패")
pass
# 크롤링에서 가져온 facility_info로 facilities 설정
print(f"[parse_marketing_analysis] 크롤링 facility_info 원본: {facility_info}")
if facility_info:
# 쉼표로 구분된 편의시설 문자열을 리스트로 변환
facilities = [f.strip() for f in facility_info.split(",") if f.strip()]
print(f"[parse_marketing_analysis] facility_info 파싱 결과: {facilities}")
else:
facilities = ["등록된 정보 없음"]
print("[parse_marketing_analysis] facility_info 없음 - '등록된 정보 없음' 설정")
# 리포트 내용을 500자로 요약
if report:
report = await self.summarize_marketing(report)
print(f"[parse_marketing_analysis] 최종 facilities: {facilities}")
return {"report": report, "tags": tags, "facilities": facilities}

View File

@ -30,6 +30,7 @@ response = await creatomate.make_creatomate_call(template_id, modifications)
"""
import copy
import logging
import time
from typing import Literal
@ -37,6 +38,9 @@ import httpx
from config import apikey_settings, creatomate_settings
# 로거 설정
logger = logging.getLogger(__name__)
# Orientation 타입 정의
OrientationType = Literal["horizontal", "vertical"]
@ -138,11 +142,51 @@ class CreatomateService:
"Authorization": f"Bearer {self.api_key}",
}
async def _request(
self,
method: str,
url: str,
timeout: float = 30.0,
**kwargs,
) -> httpx.Response:
"""HTTP 요청을 수행합니다.
Args:
method: HTTP 메서드 ("GET", "POST", etc.)
url: 요청 URL
timeout: 요청 타임아웃 ()
**kwargs: httpx 요청에 전달할 추가 인자
Returns:
httpx.Response: 응답 객체
Raises:
httpx.HTTPError: 요청 실패
"""
logger.info(f"[Creatomate] {method} {url}")
print(f"[Creatomate] {method} {url}")
client = await get_shared_client()
if method.upper() == "GET":
response = await client.get(
url, headers=self.headers, timeout=timeout, **kwargs
)
elif method.upper() == "POST":
response = await client.post(
url, headers=self.headers, timeout=timeout, **kwargs
)
else:
raise ValueError(f"Unsupported HTTP method: {method}")
logger.info(f"[Creatomate] Response - Status: {response.status_code}")
print(f"[Creatomate] Response - Status: {response.status_code}")
return response
async def get_all_templates_data(self) -> dict:
"""모든 템플릿 정보를 조회합니다."""
url = f"{self.BASE_URL}/v1/templates"
client = await get_shared_client()
response = await client.get(url, headers=self.headers, timeout=30.0)
response = await self._request("GET", url, timeout=30.0)
response.raise_for_status()
return response.json()
@ -175,8 +219,7 @@ class CreatomateService:
# API 호출
url = f"{self.BASE_URL}/v1/templates/{template_id}"
client = await get_shared_client()
response = await client.get(url, headers=self.headers, timeout=30.0)
response = await self._request("GET", url, timeout=30.0)
response.raise_for_status()
data = response.json()
@ -331,10 +374,7 @@ class CreatomateService:
"template_id": template_id,
"modifications": modifications,
}
client = await get_shared_client()
response = await client.post(
url, json=data, headers=self.headers, timeout=60.0
)
response = await self._request("POST", url, timeout=60.0, json=data)
response.raise_for_status()
return response.json()
@ -345,10 +385,7 @@ class CreatomateService:
response에 요청 정보가 있으니 폴링 필요
"""
url = f"{self.BASE_URL}/v2/renders"
client = await get_shared_client()
response = await client.post(
url, json=source, headers=self.headers, timeout=60.0
)
response = await self._request("POST", url, timeout=60.0, json=source)
response.raise_for_status()
return response.json()
@ -379,8 +416,7 @@ class CreatomateService:
- failed: 실패
"""
url = f"{self.BASE_URL}/v1/renders/{render_id}"
client = await get_shared_client()
response = await client.get(url, headers=self.headers, timeout=30.0)
response = await self._request("GET", url, timeout=30.0)
response.raise_for_status()
return response.json()

113
app/utils/nvMapPwScraper.py Normal file
View File

@ -0,0 +1,113 @@
import asyncio
from playwright.async_api import async_playwright
from urllib import parse
class nvMapPwScraper():
# cls vars
is_ready = False
_playwright = None
_browser = None
_context = None
_win_width = 1280
_win_height = 720
_max_retry = 30 # place id timeout threshold seconds
# instance var
page = None
@classmethod
def default_context_builder(cls):
context_builder_dict = {}
context_builder_dict['viewport'] = {
'width' : cls._win_width,
'height' : cls._win_height
}
context_builder_dict['screen'] = {
'width' : cls._win_width,
'height' : cls._win_height
}
context_builder_dict['user_agent'] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"
context_builder_dict['locale'] = 'ko-KR'
context_builder_dict['timezone_id']='Asia/Seoul'
return context_builder_dict
@classmethod
async def initiate_scraper(cls):
if not cls._playwright:
cls._playwright = await async_playwright().start()
if not cls._browser:
cls._browser = await cls._playwright.chromium.launch(headless=True)
if not cls._context:
cls._context = await cls._browser.new_context(**cls.default_context_builder())
cls.is_ready = True
def __init__(self):
if not self.is_ready:
raise Exception("nvMapScraper is not initiated")
async def __aenter__(self):
await self.create_page()
return self
async def __aexit__(self, exc_type, exc, tb):
await self.page.close()
async def create_page(self):
self.page = await self._context.new_page()
await self.page.add_init_script(
'''const defaultGetter = Object.getOwnPropertyDescriptor(
Navigator.prototype,
"webdriver"
).get;
defaultGetter.apply(navigator);
defaultGetter.toString();
Object.defineProperty(Navigator.prototype, "webdriver", {
set: undefined,
enumerable: true,
configurable: true,
get: new Proxy(defaultGetter, {
apply: (target, thisArg, args) => {
Reflect.apply(target, thisArg, args);
return false;
},
}),
});
const patchedGetter = Object.getOwnPropertyDescriptor(
Navigator.prototype,
"webdriver"
).get;
patchedGetter.apply(navigator);
patchedGetter.toString();''')
await self.page.set_extra_http_headers({
'sec-ch-ua': '\"Not?A_Brand\";v=\"99\", \"Chromium\";v=\"130\"'
})
await self.page.goto("http://google.com")
async def goto_url(self, url, wait_until="domcontentloaded", timeout=20000):
page = self.page
await page.goto(url, wait_until=wait_until, timeout=timeout)
async def get_place_id_url(self, selected):
title = selected['title'].replace("<b>", "").replace("</b>", "")
address = selected.get('roadAddress', selected['address']).replace("<b>", "").replace("</b>", "")
encoded_query = parse.quote(f"{address} {title}")
url = f"https://map.naver.com/p/search/{encoded_query}"
await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000)
if "/place/" in self.page.url:
return self.page.url
url = self.page.url.replace("?","?isCorrectAnswer=true&")
await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000)
if "/place/" in self.page.url:
return self.page.url
if (count == self._max_retry / 2):
raise Exception("Failed to identify place id. loading timeout")
else:
raise Exception("Failed to identify place id. item is ambiguous")

View File

@ -1,17 +1,35 @@
import asyncio
import json
import logging
import re
import aiohttp
import bs4
from config import crawler_settings
# 로거 설정
logger = logging.getLogger(__name__)
class GraphQLException(Exception):
"""GraphQL 요청 실패 시 발생하는 예외"""
pass
class CrawlingTimeoutException(Exception):
"""크롤링 타임아웃 시 발생하는 예외"""
pass
class NvMapScraper:
"""네이버 지도 GraphQL API 스크래퍼
네이버 지도에서 숙소/장소 정보를 크롤링합니다.
"""
GRAPHQL_URL: str = "https://pcmap-api.place.naver.com/graphql"
REQUEST_TIMEOUT = 120 # 초
OVERVIEW_QUERY: str = """
query getAccommodation($id: String!, $deviceType: String) {
@ -49,6 +67,7 @@ query getAccommodation($id: String!, $deviceType: String) {
self.rawdata: dict | None = None
self.image_link_list: list[str] | None = None
self.base_info: dict | None = None
self.facility_info: str | None = None
def _get_request_headers(self) -> dict:
headers = self.DEFAULT_HEADERS.copy()
@ -56,8 +75,19 @@ query getAccommodation($id: String!, $deviceType: String) {
headers["Cookie"] = self.cookies
return headers
def parse_url(self) -> str:
async def parse_url(self) -> str:
"""URL에서 place ID를 추출합니다. 단축 URL인 경우 실제 URL로 변환합니다."""
place_pattern = r"/place/(\d+)"
# URL에 place가 없는 경우 단축 URL 처리
if "place" not in self.url:
if "naver.me" in self.url:
async with aiohttp.ClientSession() as session:
async with session.get(self.url) as response:
self.url = str(response.url)
else:
raise GraphQLException("This URL does not contain a place ID")
match = re.search(place_pattern, self.url)
if not match:
raise GraphQLException("Failed to parse place ID from URL")
@ -65,14 +95,17 @@ query getAccommodation($id: String!, $deviceType: String) {
async def scrap(self):
try:
place_id = self.parse_url()
place_id = await self.parse_url()
data = await self._call_get_accommodation(place_id)
self.rawdata = data
fac_data = await self._get_facility_string(place_id)
self.rawdata["facilities"] = fac_data
self.image_link_list = [
nv_image["origin"]
for nv_image in data["data"]["business"]["images"]["images"]
]
self.base_info = data["data"]["business"]["base"]
self.facility_info = fac_data
self.scrap_type = "GraphQL"
except GraphQLException:
@ -83,25 +116,81 @@ query getAccommodation($id: String!, $deviceType: String) {
return
async def _call_get_accommodation(self, place_id: str) -> dict:
"""GraphQL API를 호출하여 숙소 정보를 가져옵니다.
Args:
place_id: 네이버 지도 장소 ID
Returns:
GraphQL 응답 데이터
Raises:
GraphQLException: API 호출 실패
CrawlingTimeoutException: 타임아웃 발생
"""
payload = {
"operationName": "getAccommodation",
"variables": {"id": place_id, "deviceType": "pc"},
"query": self.OVERVIEW_QUERY,
}
json_payload = json.dumps(payload)
timeout = aiohttp.ClientTimeout(total=self.REQUEST_TIMEOUT)
async with aiohttp.ClientSession() as session:
async with session.post(
self.GRAPHQL_URL, data=json_payload, headers=self._get_request_headers()
) as response:
if response.status == 200:
return await response.json()
else:
print("실패 상태 코드:", response.status)
try:
logger.info(f"[NvMapScraper] Requesting place_id: {place_id}")
print(f"[NvMapScraper] Requesting place_id: {place_id}")
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(
self.GRAPHQL_URL,
data=json_payload,
headers=self._get_request_headers()
) as response:
if response.status == 200:
logger.info(f"[NvMapScraper] SUCCESS - place_id: {place_id}")
print(f"[NvMapScraper] SUCCESS - place_id: {place_id}")
return await response.json()
# 실패 상태 코드
logger.error(f"[NvMapScraper] Failed with status {response.status} - place_id: {place_id}")
print(f"[NvMapScraper] 실패 상태 코드: {response.status}")
raise GraphQLException(
f"Request failed with status {response.status}"
)
except (TimeoutError, asyncio.TimeoutError):
logger.error(f"[NvMapScraper] Timeout - place_id: {place_id}")
print(f"[NvMapScraper] Timeout - place_id: {place_id}")
raise CrawlingTimeoutException(f"Request timed out after {self.REQUEST_TIMEOUT}s")
except aiohttp.ClientError as e:
logger.error(f"[NvMapScraper] Client error: {e}")
print(f"[NvMapScraper] Client error: {e}")
raise GraphQLException(f"Client error: {e}")
async def _get_facility_string(self, place_id: str) -> str | None:
"""숙소 페이지에서 편의시설 정보를 크롤링합니다.
Args:
place_id: 네이버 지도 장소 ID
Returns:
편의시설 정보 문자열 또는 None
"""
url = f"https://pcmap.place.naver.com/accommodation/{place_id}/home"
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=self._get_request_headers()) as response:
soup = bs4.BeautifulSoup(await response.read(), "html.parser")
c_elem = soup.find("span", "place_blind", string="편의")
if c_elem:
facilities = c_elem.parent.parent.find("div").string
return facilities
return None
except Exception as e:
logger.warning(f"[NvMapScraper] Failed to get facility info: {e}")
return None
# if __name__ == "__main__":
# import asyncio

View File

@ -32,6 +32,7 @@ URL 경로 형식:
"""
import asyncio
import logging
import time
from pathlib import Path
@ -40,6 +41,8 @@ import httpx
from config import azure_blob_settings
# 로거 설정
logger = logging.getLogger(__name__)
# =============================================================================
# 모듈 레벨 공유 HTTP 클라이언트 (싱글톤 패턴)
@ -138,17 +141,30 @@ class AzureBlobUploader:
timeout: float,
log_prefix: str,
) -> bool:
"""바이트 데이터를 업로드하는 공통 내부 메서드"""
"""바이트 데이터를 업로드하는 공통 내부 메서드
Args:
file_content: 업로드할 바이트 데이터
upload_url: 업로드 URL
headers: HTTP 헤더
timeout: 요청 타임아웃 ()
log_prefix: 로그 접두사
Returns:
bool: 업로드 성공 여부
"""
size = len(file_content)
start_time = time.perf_counter()
try:
logger.info(f"[{log_prefix}] Starting upload")
print(f"[{log_prefix}] Getting shared client...")
client = await get_shared_blob_client()
client_time = time.perf_counter()
elapsed_ms = (client_time - start_time) * 1000
print(f"[{log_prefix}] Client acquired in {elapsed_ms:.1f}ms")
size = len(file_content)
print(f"[{log_prefix}] Starting upload... "
f"(size: {size} bytes, timeout: {timeout}s)")
@ -160,33 +176,42 @@ class AzureBlobUploader:
duration_ms = (upload_time - start_time) * 1000
if response.status_code in [200, 201]:
logger.info(f"[{log_prefix}] SUCCESS - Status: {response.status_code}")
print(f"[{log_prefix}] SUCCESS - Status: {response.status_code}, "
f"Duration: {duration_ms:.1f}ms")
print(f"[{log_prefix}] Public URL: {self._last_public_url}")
return True
else:
print(f"[{log_prefix}] FAILED - Status: {response.status_code}, "
f"Duration: {duration_ms:.1f}ms")
print(f"[{log_prefix}] Response: {response.text[:500]}")
return False
# 업로드 실패
logger.error(f"[{log_prefix}] FAILED - Status: {response.status_code}")
print(f"[{log_prefix}] FAILED - Status: {response.status_code}, "
f"Duration: {duration_ms:.1f}ms")
print(f"[{log_prefix}] Response: {response.text[:500]}")
return False
except asyncio.TimeoutError:
elapsed = time.perf_counter() - start_time
print(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s "
f"(limit: {timeout}s)")
logger.error(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s")
print(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s")
return False
except httpx.ConnectError as e:
elapsed = time.perf_counter() - start_time
logger.error(f"[{log_prefix}] CONNECT_ERROR: {e}")
print(f"[{log_prefix}] CONNECT_ERROR after {elapsed:.1f}s - "
f"{type(e).__name__}: {e}")
return False
except httpx.ReadError as e:
elapsed = time.perf_counter() - start_time
logger.error(f"[{log_prefix}] READ_ERROR: {e}")
print(f"[{log_prefix}] READ_ERROR after {elapsed:.1f}s - "
f"{type(e).__name__}: {e}")
return False
except Exception as e:
elapsed = time.perf_counter() - start_time
logger.error(f"[{log_prefix}] ERROR: {type(e).__name__}: {e}")
print(f"[{log_prefix}] ERROR after {elapsed:.1f}s - "
f"{type(e).__name__}: {e}")
return False

BIN
app/video/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -783,7 +783,7 @@ async def make_automation(request: Request, conn: Connection):
else "",
"attr_value": ", ".join(selected_values) if selected_values else "",
"ai": "ChatGPT",
"ai_model": "gpt-4o",
"ai_model": "gpt-5-mini",
"genre": "후크송",
"sample_song": combined_sample_song or "없음",
"result_song": final_lyrics,

View File

@ -4,16 +4,109 @@ Video Background Tasks
영상 생성 관련 백그라운드 태스크를 정의합니다.
"""
import logging
import traceback
from pathlib import Path
import aiofiles
import httpx
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from app.database.session import BackgroundSessionLocal
from app.video.models import Video
from app.utils.upload_blob_as_request import AzureBlobUploader
# 로거 설정
logger = logging.getLogger(__name__)
# HTTP 요청 설정
REQUEST_TIMEOUT = 300.0 # 초 (영상은 용량이 크므로 5분)
async def _update_video_status(
task_id: str,
status: str,
video_url: str | None = None,
creatomate_render_id: str | None = None,
) -> bool:
"""Video 테이블의 상태를 업데이트합니다.
Args:
task_id: 프로젝트 task_id
status: 변경할 상태 ("processing", "completed", "failed")
video_url: 영상 URL
creatomate_render_id: Creatomate render ID (선택)
Returns:
bool: 업데이트 성공 여부
"""
try:
async with BackgroundSessionLocal() as session:
if creatomate_render_id:
query_result = await session.execute(
select(Video)
.where(Video.creatomate_render_id == creatomate_render_id)
.order_by(Video.created_at.desc())
.limit(1)
)
else:
query_result = await session.execute(
select(Video)
.where(Video.task_id == task_id)
.order_by(Video.created_at.desc())
.limit(1)
)
video = query_result.scalar_one_or_none()
if video:
video.status = status
if video_url is not None:
video.result_movie_url = video_url
await session.commit()
logger.info(f"[Video] Status updated - task_id: {task_id}, status: {status}")
print(f"[Video] Status updated - task_id: {task_id}, status: {status}")
return True
else:
logger.warning(f"[Video] NOT FOUND in DB - task_id: {task_id}")
print(f"[Video] NOT FOUND in DB - task_id: {task_id}")
return False
except SQLAlchemyError as e:
logger.error(f"[Video] DB Error while updating status - task_id: {task_id}, error: {e}")
print(f"[Video] DB Error while updating status - task_id: {task_id}, error: {e}")
return False
except Exception as e:
logger.error(f"[Video] Unexpected error while updating status - task_id: {task_id}, error: {e}")
print(f"[Video] Unexpected error while updating status - task_id: {task_id}, error: {e}")
return False
async def _download_video(url: str, task_id: str) -> bytes:
"""URL에서 영상을 다운로드합니다.
Args:
url: 다운로드할 URL
task_id: 로그용 task_id
Returns:
bytes: 다운로드한 파일 내용
Raises:
httpx.HTTPError: 다운로드 실패
"""
logger.info(f"[VideoDownload] Downloading - task_id: {task_id}")
print(f"[VideoDownload] Downloading - task_id: {task_id}")
async with httpx.AsyncClient() as client:
response = await client.get(url, timeout=REQUEST_TIMEOUT)
response.raise_for_status()
logger.info(f"[VideoDownload] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes")
print(f"[VideoDownload] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes")
return response.content
async def download_and_upload_video_to_blob(
task_id: str,
@ -27,6 +120,7 @@ async def download_and_upload_video_to_blob(
video_url: 다운로드할 영상 URL
store_name: 저장할 파일명에 사용할 업체명
"""
logger.info(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, store_name: {store_name}")
print(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, store_name: {store_name}")
temp_file_path: Path | None = None
@ -42,16 +136,19 @@ async def download_and_upload_video_to_blob(
temp_dir = Path("media") / "temp" / task_id
temp_dir.mkdir(parents=True, exist_ok=True)
temp_file_path = temp_dir / file_name
logger.info(f"[download_and_upload_video_to_blob] Temp directory created - path: {temp_file_path}")
print(f"[download_and_upload_video_to_blob] Temp directory created - path: {temp_file_path}")
# 영상 파일 다운로드
logger.info(f"[download_and_upload_video_to_blob] Downloading video - task_id: {task_id}, url: {video_url}")
print(f"[download_and_upload_video_to_blob] Downloading video - task_id: {task_id}, url: {video_url}")
async with httpx.AsyncClient() as client:
response = await client.get(video_url, timeout=180.0)
response.raise_for_status()
async with aiofiles.open(str(temp_file_path), "wb") as f:
await f.write(response.content)
content = await _download_video(video_url, task_id)
async with aiofiles.open(str(temp_file_path), "wb") as f:
await f.write(content)
logger.info(f"[download_and_upload_video_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}")
print(f"[download_and_upload_video_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}")
# Azure Blob Storage에 업로드
@ -63,51 +160,41 @@ async def download_and_upload_video_to_blob(
# SAS 토큰이 제외된 public_url 사용
blob_url = uploader.public_url
logger.info(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
print(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
# Video 테이블 업데이트 (새 세션 사용)
async with BackgroundSessionLocal() as session:
# 여러 개 있을 경우 가장 최근 것 선택
result = await session.execute(
select(Video)
.where(Video.task_id == task_id)
.order_by(Video.created_at.desc())
.limit(1)
)
video = result.scalar_one_or_none()
# Video 테이블 업데이트
await _update_video_status(task_id, "completed", blob_url)
logger.info(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}")
print(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}")
if video:
video.status = "completed"
video.result_movie_url = blob_url
await session.commit()
print(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}, status: completed")
else:
print(f"[download_and_upload_video_to_blob] Video NOT FOUND in DB - task_id: {task_id}")
except httpx.HTTPError as e:
logger.error(f"[download_and_upload_video_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
print(f"[download_and_upload_video_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
traceback.print_exc()
await _update_video_status(task_id, "failed")
except SQLAlchemyError as e:
logger.error(f"[download_and_upload_video_to_blob] DB ERROR - task_id: {task_id}, error: {e}")
print(f"[download_and_upload_video_to_blob] DB ERROR - task_id: {task_id}, error: {e}")
traceback.print_exc()
await _update_video_status(task_id, "failed")
except Exception as e:
logger.error(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
print(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
# 실패 시 Video 테이블 업데이트
async with BackgroundSessionLocal() as session:
result = await session.execute(
select(Video)
.where(Video.task_id == task_id)
.order_by(Video.created_at.desc())
.limit(1)
)
video = result.scalar_one_or_none()
if video:
video.status = "failed"
await session.commit()
print(f"[download_and_upload_video_to_blob] FAILED - task_id: {task_id}, status updated to failed")
traceback.print_exc()
await _update_video_status(task_id, "failed")
finally:
# 임시 파일 삭제
if temp_file_path and temp_file_path.exists():
try:
temp_file_path.unlink()
logger.info(f"[download_and_upload_video_to_blob] Temp file deleted - path: {temp_file_path}")
print(f"[download_and_upload_video_to_blob] Temp file deleted - path: {temp_file_path}")
except Exception as e:
logger.warning(f"[download_and_upload_video_to_blob] Failed to delete temp file: {e}")
print(f"[download_and_upload_video_to_blob] Failed to delete temp file: {e}")
# 임시 디렉토리 삭제 시도
@ -131,6 +218,7 @@ async def download_and_upload_video_by_creatomate_render_id(
video_url: 다운로드할 영상 URL
store_name: 저장할 파일명에 사용할 업체명
"""
logger.info(f"[download_and_upload_video_by_creatomate_render_id] START - creatomate_render_id: {creatomate_render_id}, store_name: {store_name}")
print(f"[download_and_upload_video_by_creatomate_render_id] START - creatomate_render_id: {creatomate_render_id}, store_name: {store_name}")
temp_file_path: Path | None = None
task_id: str | None = None
@ -147,10 +235,12 @@ async def download_and_upload_video_by_creatomate_render_id(
video = result.scalar_one_or_none()
if not video:
logger.warning(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND - creatomate_render_id: {creatomate_render_id}")
print(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND - creatomate_render_id: {creatomate_render_id}")
return
task_id = video.task_id
logger.info(f"[download_and_upload_video_by_creatomate_render_id] Video found - creatomate_render_id: {creatomate_render_id}, task_id: {task_id}")
print(f"[download_and_upload_video_by_creatomate_render_id] Video found - creatomate_render_id: {creatomate_render_id}, task_id: {task_id}")
# 파일명에 사용할 수 없는 문자 제거
@ -164,16 +254,19 @@ async def download_and_upload_video_by_creatomate_render_id(
temp_dir = Path("media") / "temp" / task_id
temp_dir.mkdir(parents=True, exist_ok=True)
temp_file_path = temp_dir / file_name
logger.info(f"[download_and_upload_video_by_creatomate_render_id] Temp directory created - path: {temp_file_path}")
print(f"[download_and_upload_video_by_creatomate_render_id] Temp directory created - path: {temp_file_path}")
# 영상 파일 다운로드
logger.info(f"[download_and_upload_video_by_creatomate_render_id] Downloading video - creatomate_render_id: {creatomate_render_id}, url: {video_url}")
print(f"[download_and_upload_video_by_creatomate_render_id] Downloading video - creatomate_render_id: {creatomate_render_id}, url: {video_url}")
async with httpx.AsyncClient() as client:
response = await client.get(video_url, timeout=180.0)
response.raise_for_status()
async with aiofiles.open(str(temp_file_path), "wb") as f:
await f.write(response.content)
content = await _download_video(video_url, task_id)
async with aiofiles.open(str(temp_file_path), "wb") as f:
await f.write(content)
logger.info(f"[download_and_upload_video_by_creatomate_render_id] File downloaded - creatomate_render_id: {creatomate_render_id}, path: {temp_file_path}")
print(f"[download_and_upload_video_by_creatomate_render_id] File downloaded - creatomate_render_id: {creatomate_render_id}, path: {temp_file_path}")
# Azure Blob Storage에 업로드
@ -185,51 +278,49 @@ async def download_and_upload_video_by_creatomate_render_id(
# SAS 토큰이 제외된 public_url 사용
blob_url = uploader.public_url
logger.info(f"[download_and_upload_video_by_creatomate_render_id] Uploaded to Blob - creatomate_render_id: {creatomate_render_id}, url: {blob_url}")
print(f"[download_and_upload_video_by_creatomate_render_id] Uploaded to Blob - creatomate_render_id: {creatomate_render_id}, url: {blob_url}")
# Video 테이블 업데이트 (새 세션 사용)
async with BackgroundSessionLocal() as session:
result = await session.execute(
select(Video)
.where(Video.creatomate_render_id == creatomate_render_id)
.order_by(Video.created_at.desc())
.limit(1)
)
video = result.scalar_one_or_none()
# Video 테이블 업데이트
await _update_video_status(
task_id=task_id,
status="completed",
video_url=blob_url,
creatomate_render_id=creatomate_render_id,
)
logger.info(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}")
print(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}")
if video:
video.status = "completed"
video.result_movie_url = blob_url
await session.commit()
print(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}, status: completed")
else:
print(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND in DB - creatomate_render_id: {creatomate_render_id}")
except httpx.HTTPError as e:
logger.error(f"[download_and_upload_video_by_creatomate_render_id] DOWNLOAD ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}")
print(f"[download_and_upload_video_by_creatomate_render_id] DOWNLOAD ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}")
traceback.print_exc()
if task_id:
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
except SQLAlchemyError as e:
logger.error(f"[download_and_upload_video_by_creatomate_render_id] DB ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}")
print(f"[download_and_upload_video_by_creatomate_render_id] DB ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}")
traceback.print_exc()
if task_id:
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
except Exception as e:
logger.error(f"[download_and_upload_video_by_creatomate_render_id] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}")
print(f"[download_and_upload_video_by_creatomate_render_id] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}")
# 실패 시 Video 테이블 업데이트
traceback.print_exc()
if task_id:
async with BackgroundSessionLocal() as session:
result = await session.execute(
select(Video)
.where(Video.creatomate_render_id == creatomate_render_id)
.order_by(Video.created_at.desc())
.limit(1)
)
video = result.scalar_one_or_none()
if video:
video.status = "failed"
await session.commit()
print(f"[download_and_upload_video_by_creatomate_render_id] FAILED - creatomate_render_id: {creatomate_render_id}, status updated to failed")
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
finally:
# 임시 파일 삭제
if temp_file_path and temp_file_path.exists():
try:
temp_file_path.unlink()
logger.info(f"[download_and_upload_video_by_creatomate_render_id] Temp file deleted - path: {temp_file_path}")
print(f"[download_and_upload_video_by_creatomate_render_id] Temp file deleted - path: {temp_file_path}")
except Exception as e:
logger.warning(f"[download_and_upload_video_by_creatomate_render_id] Failed to delete temp file: {e}")
print(f"[download_and_upload_video_by_creatomate_render_id] Failed to delete temp file: {e}")
# 임시 디렉토리 삭제 시도

BIN
docs/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -31,7 +31,6 @@ O2O-CASTAD Backend는 FastAPI 기반의 비동기 백엔드 서비스로, AI 기
| 영역 | 개선 전 | 개선 후 | 개선율 |
|------|---------|---------|--------|
| DB 쿼리 실행 | 순차 (200ms) | 병렬 (55ms) | **72% 감소** |
| 템플릿 API 호출 | 매번 호출 (1-2s) | 캐시 HIT (0ms) | **100% 감소** |
| HTTP 클라이언트 | 매번 생성 (50ms) | 풀 재사용 (0ms) | **100% 감소** |
| 세션 타임아웃 에러 | 빈번 | 해결 | **안정성 확보** |
@ -41,7 +40,7 @@ O2O-CASTAD Backend는 FastAPI 기반의 비동기 백엔드 서비스로, AI 기
1. **이중 커넥션 풀 아키텍처**: 요청/백그라운드 분리
2. **명시적 세션 라이프사이클**: 외부 API 호출 전 세션 해제
3. **모듈 레벨 싱글톤**: HTTP 클라이언트 및 템플릿 캐시
4. **asyncio.gather() 기반 병렬 쿼리**: 다중 테이블 동시 조회
4. **순차 쿼리 실행**: AsyncSession 제약으로 단일 세션 내 병렬 쿼리 불가
---
@ -78,7 +77,7 @@ engine = create_async_engine(
pool_size=20, # 기본 풀 크기
max_overflow=20, # 추가 연결 허용
pool_timeout=30, # 연결 대기 최대 시간
pool_recycle=3600, # 1시간마다 연결 재생성
pool_recycle=280, # MySQL wait_timeout 보다 짧게 설정
pool_pre_ping=True, # 연결 유효성 검사 (핵심!)
pool_reset_on_return="rollback", # 반환 시 롤백
)
@ -89,8 +88,9 @@ background_engine = create_async_engine(
pool_size=10, # 더 작은 풀
max_overflow=10,
pool_timeout=60, # 백그라운드는 대기 여유
pool_recycle=3600,
pool_recycle=280, # MySQL wait_timeout 보다 짧게 설정
pool_pre_ping=True,
pool_reset_on_return="rollback", # 반환 시 롤백
)
```
@ -120,8 +120,10 @@ async def get_item(
async def generate_video(task_id: str):
# 1단계: 명시적 세션 열기 → DB 작업 → 세션 닫기
async with AsyncSessionLocal() as session:
# 병렬 쿼리 실행
results = await asyncio.gather(...)
# 순차 쿼리 실행 (AsyncSession은 병렬 쿼리 미지원)
project = await session.execute(select(Project).where(...))
lyric = await session.execute(select(Lyric).where(...))
song = await session.execute(select(Song).where(...))
# 초기 데이터 저장
await session.commit()
# 세션 닫힘 (async with 블록 종료)
@ -193,38 +195,47 @@ async def generate_video():
## 3. 비동기 처리 패턴
### 3.1 asyncio.gather() 병렬 쿼리
### 3.1 순차 쿼리 실행 (AsyncSession 제약)
**위치**: `app/video/api/routers/v1/video.py`
> **중요**: SQLAlchemy AsyncSession은 단일 세션에서 동시에 여러 쿼리를 실행하는 것을 지원하지 않습니다.
> `asyncio.gather()`로 병렬 쿼리를 실행하면 세션 상태 충돌이 발생합니다.
```python
# 4개의 독립적인 쿼리를 병렬로 실행
project_result, lyric_result, song_result, image_result = (
await asyncio.gather(
session.execute(project_query),
session.execute(lyric_query),
session.execute(song_query),
session.execute(image_query),
)
# 순차 쿼리 실행: Project, Lyric, Song, Image
# Note: AsyncSession은 동일 세션에서 병렬 쿼리를 지원하지 않음
# Project 조회
project_result = await session.execute(
select(Project).where(Project.task_id == task_id)
.order_by(Project.created_at.desc()).limit(1)
)
# Lyric 조회
lyric_result = await session.execute(
select(Lyric).where(Lyric.task_id == task_id)
.order_by(Lyric.created_at.desc()).limit(1)
)
# Song 조회
song_result = await session.execute(
select(Song).where(Song.task_id == task_id)
.order_by(Song.created_at.desc()).limit(1)
)
# Image 조회
image_result = await session.execute(
select(Image).where(Image.task_id == task_id)
.order_by(Image.img_order.asc())
)
```
**성능 비교:**
```
[순차 실행]
Query 1 ──────▶ 50ms
Query 2 ──────▶ 50ms
Query 3 ──────▶ 50ms
Query 4 ──────▶ 50ms
총 소요시간: 200ms
**AsyncSession 병렬 쿼리 제약 사항:**
[병렬 실행]
Query 1 ──────▶ 50ms
Query 2 ──────▶ 50ms
Query 3 ──────▶ 50ms
Query 4 ──────▶ 50ms
총 소요시간: ~55ms (가장 느린 쿼리 + 오버헤드)
```
- 단일 세션 내에서 `asyncio.gather()`로 여러 쿼리 동시 실행 불가
- 세션 상태 충돌 및 예기치 않은 동작 발생 가능
- 병렬 쿼리가 필요한 경우 별도의 세션을 각각 생성해야 함
### 3.2 FastAPI BackgroundTasks 활용
@ -553,7 +564,6 @@ query = (
| 요소 | 구현 | 효과 |
|------|------|------|
| asyncio.gather() | 병렬 쿼리 | 72% 응답 시간 단축 |
| 템플릿 캐싱 | TTL 기반 메모리 캐시 | API 호출 100% 감소 |
| HTTP 클라이언트 풀 | 싱글톤 패턴 | 커넥션 재사용 |
| N+1 해결 | IN 절 배치 조회 | 쿼리 수 N→2 감소 |
@ -721,7 +731,7 @@ async def generate_video_with_lock(task_id: str):
5. 영상 생성
Client ─▶ GET /video/generate/{task_id}
├─ asyncio.gather() ─▶ DB(Project, Lyric, Song, Image)
├─ 순차 쿼리 ─▶ DB(Project, Lyric, Song, Image)
├─ Creatomate API ─▶ render_id
@ -750,7 +760,7 @@ async def generate_video_with_lock(task_id: str):
O2O-CASTAD Backend는 **프로덕션 준비 수준의 비동기 아키텍처**를 갖추고 있습니다:
1. **안정성**: 이중 커넥션 풀, pool_pre_ping, 명시적 세션 관리로 런타임 에러 최소화
2. **성능**: 병렬 쿼리, 캐싱, HTTP 클라이언트 풀링으로 응답 시간 최적화
2. **성능**: 캐싱, HTTP 클라이언트 풀링으로 응답 시간 최적화
3. **확장성**: 백그라운드 태스크 분리, 폴링 패턴으로 부하 분산
4. **유지보수성**: 일관된 패턴, 구조화된 로깅, 타입 힌트
@ -761,7 +771,6 @@ O2O-CASTAD Backend는 **프로덕션 준비 수준의 비동기 아키텍처**
│ BEFORE → AFTER │
├─────────────────────────────────────────────────────────────┤
│ Session Timeout Errors │ Frequent → Resolved │
│ DB Query Time │ 200ms → 55ms (72%↓) │
│ Template API Calls │ Every req → Cached (100%↓) │
│ HTTP Client Overhead │ 50ms/req → 0ms (100%↓) │
│ N+1 Query Problem │ N queries → 2 queries │

View File

@ -125,7 +125,7 @@ Generate lyrics now:""")
])
# 체인 구성
lyric_chain = LYRIC_PROMPT | ChatOpenAI(model="gpt-4o-mini") | StrOutputParser()
lyric_chain = LYRIC_PROMPT | ChatOpenAI(model="gpt-5-mini-mini") | StrOutputParser()
# 사용
result = await lyric_chain.ainvoke({
@ -234,7 +234,7 @@ parser = PydanticOutputParser(pydantic_object=LyricOutput)
# 자동 수정 파서 (파싱 실패 시 LLM으로 재시도)
fixing_parser = OutputFixingParser.from_llm(
parser=parser,
llm=ChatOpenAI(model="gpt-4o-mini")
llm=ChatOpenAI(model="gpt-5-mini-mini")
)
# 프롬프트에 포맷 지시 추가
@ -1034,7 +1034,7 @@ async def generate_lyrics_with_rag(
""")
])
chain = rag_prompt | ChatOpenAI(model="gpt-4o") | StrOutputParser()
chain = rag_prompt | ChatOpenAI(model="gpt-5-mini") | StrOutputParser()
result = await chain.ainvoke({
"examples": examples_text,
@ -1128,7 +1128,7 @@ async def enrich_lyrics_with_region_info(
from langchain_openai import ChatOpenAI
# Vision 모델로 이미지 분석
vision_model = ChatOpenAI(model="gpt-4o")
vision_model = ChatOpenAI(model="gpt-5-mini")
# 이미지 분석 문서 구조
class ImageAnalysis(BaseModel):
@ -1151,7 +1151,7 @@ image_store = Chroma(
async def analyze_and_store_image(image_url: str, task_id: str):
"""이미지 분석 후 벡터 스토어에 저장"""
# GPT-4o Vision으로 이미지 분석
# gpt-5-mini Vision으로 이미지 분석
analysis_response = await vision_model.ainvoke([
{
"type": "text",

View File

@ -801,7 +801,7 @@ from app.infrastructure.external.chatgpt.prompts import LYRICS_PROMPT_TEMPLATE
class ChatGPTClient(ILLMClient):
"""ChatGPT 클라이언트 구현"""
def __init__(self, api_key: str, model: str = "gpt-4o"):
def __init__(self, api_key: str, model: str = "gpt-5-mini"):
self._client = AsyncOpenAI(api_key=api_key)
self._model = model
@ -956,7 +956,7 @@ def get_chatgpt_client(
) -> ChatGPTClient:
return ChatGPTClient(
api_key=settings.CHATGPT_API_KEY,
model="gpt-4o"
model="gpt-5-mini"
)
def get_suno_client(

BIN
image/.DS_Store vendored Normal file

Binary file not shown.

BIN
image/2025-12-26/.DS_Store vendored Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

BIN
poc/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,29 @@
import asyncio
from nvMapScraper import nvMapScraper
from nvMapPwScraper import nvMapPwScraper
async def main_function():
await nvMapPwScraper.initiate_scraper()
selected = {'title': '<b>스테이</b>,<b>머뭄</b>',
'link': 'https://www.instagram.com/staymeomoom',
'category': '숙박>펜션',
'description': '',
'telephone': '',
'address': '전북특별자치도 군산시 신흥동 63-18',
'roadAddress': '전북특별자치도 군산시 절골길 18',
'mapx': '1267061254',
'mapy': '359864175',
'lng': 126.7061254,
'lat': 35.9864175}
async with nvMapPwScraper() as pw_scraper:
new_url = await pw_scraper.get_place_id_url(selected)
print(new_url)
nv_scraper = nvMapScraper(new_url) # 이후 동일한 플로우
await nv_scraper.scrap()
print(nv_scraper.rawdata)
return
print("running main_funtion..")
asyncio.run(main_function())

View File

@ -0,0 +1,113 @@
import asyncio
from playwright.async_api import async_playwright
from urllib import parse
class nvMapPwScraper():
# cls vars
is_ready = False
_playwright = None
_browser = None
_context = None
_win_width = 1280
_win_height = 720
_max_retry = 30 # place id timeout threshold seconds
# instance var
page = None
@classmethod
def default_context_builder(cls):
context_builder_dict = {}
context_builder_dict['viewport'] = {
'width' : cls._win_width,
'height' : cls._win_height
}
context_builder_dict['screen'] = {
'width' : cls._win_width,
'height' : cls._win_height
}
context_builder_dict['user_agent'] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"
context_builder_dict['locale'] = 'ko-KR'
context_builder_dict['timezone_id']='Asia/Seoul'
return context_builder_dict
@classmethod
async def initiate_scraper(cls):
if not cls._playwright:
cls._playwright = await async_playwright().start()
if not cls._browser:
cls._browser = await cls._playwright.chromium.launch(headless=True)
if not cls._context:
cls._context = await cls._browser.new_context(**cls.default_context_builder())
cls.is_ready = True
def __init__(self):
if not self.is_ready:
raise Exception("nvMapScraper is not initiated")
async def __aenter__(self):
await self.create_page()
return self
async def __aexit__(self, exc_type, exc, tb):
await self.page.close()
async def create_page(self):
self.page = await self._context.new_page()
await self.page.add_init_script(
'''const defaultGetter = Object.getOwnPropertyDescriptor(
Navigator.prototype,
"webdriver"
).get;
defaultGetter.apply(navigator);
defaultGetter.toString();
Object.defineProperty(Navigator.prototype, "webdriver", {
set: undefined,
enumerable: true,
configurable: true,
get: new Proxy(defaultGetter, {
apply: (target, thisArg, args) => {
Reflect.apply(target, thisArg, args);
return false;
},
}),
});
const patchedGetter = Object.getOwnPropertyDescriptor(
Navigator.prototype,
"webdriver"
).get;
patchedGetter.apply(navigator);
patchedGetter.toString();''')
await self.page.set_extra_http_headers({
'sec-ch-ua': '\"Not?A_Brand\";v=\"99\", \"Chromium\";v=\"130\"'
})
await self.page.goto("http://google.com")
async def goto_url(self, url, wait_until="domcontentloaded", timeout=20000):
page = self.page
await page.goto(url, wait_until=wait_until, timeout=timeout)
async def get_place_id_url(self, selected):
title = selected['title'].replace("<b>", "").replace("</b>", "")
address = selected.get('roadAddress', selected['address']).replace("<b>", "").replace("</b>", "")
encoded_query = parse.quote(f"{address} {title}")
url = f"https://map.naver.com/p/search/{encoded_query}"
await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000)
if "/place/" in self.page.url:
return self.page.url
url = self.page.url.replace("?","?isCorrectAnswer=true&")
await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000)
if "/place/" in self.page.url:
return self.page.url
if (count == self._max_retry / 2):
raise Exception("Failed to identify place id. loading timeout")
else:
raise Exception("Failed to identify place id. item is ambiguous")

View File

@ -0,0 +1,119 @@
import re
import aiohttp
import json
import asyncio
import bs4
PLACE_PATTERN = r"/place/(\d+)"
GRAPHQL_URL = "https://pcmap-api.place.naver.com/graphql"
NAVER_COOKIES="NAC=mQ7mBownbQf4A; NNB=TQPII6AKDBFGQ; PLACE_LANGUAGE=ko; NACT=1; nid_inf=1431570813; NID_AUT=k2T7FraXOdIMRCHzEZIFtHQup+I7b87M5fd7+p65AXZTdGB/gelRmW8s/Q4oDxm8; tooltipDisplayed=true; SRT30=1762660151; NID_SES=AAAB1Lpy3y3hGzuPbJpJl8vvFx18C+HXXuZEFou/YPgocHe7k2/5MpFlgE48X1JF7c7IPoU2khZKkkuLx+tsvWAzOf0TnG/G8RrBGeawnSluSJcKcTdKKRJ4cygKc/OabVxoc3TNZJWxer3vFtXBoXkDS5querVNS6wvcMhA/p4vkPKOeepwKLR+1IJERlQJWZw4q29IdAysrbBNn3Akf9mDA5eTYvMDLYyRkToRh10TVMW/yhyNQeMXlIdnR8U1ZCNqe/9ErYdos5gQDstswEJQQA0T2cHFGJOtmlYMPlnhWado5w521iZXGJyKcA9ZawizM/i5nK5xNYtPGS3cvImUYl6B5ulIipUJSqpj8v2XstK0TZlOGxHToXaVDrCNmSfCA9vFYbTb6xJHB2JRAT3Jik/z6QgLjJLBWRnsucMDqldxoiEDAUHEhY3pjgZ89quR3c3hwAuTlI9hBn5I3e5VQR0Y/GxoS9mIkMF8pJmcGneqnE0BNIt91RN6Se5rDM69B+JWppBXtSir1JGuXADaRLLMP8VlxJX949iH0UYTKWKsrD4OgNNK5aUx24nAH494WPknBMlx4fCMIeWzy7K3sEZkNUn/+A+eHraqIFfbGpveSCNM+8EqEjMgA+YRgg3eig==; _naver_usersession_=Kkgzim/64JicPJzgkIIvqQ==; page_uid=jesTPsqVWUZssE4qJeossssssD0-011300; SRT5=1762662010; BUC=z5Fu3sAYtFwpbRDrrDFYdn4AgK5hNkOqX-DdaLU7VJM="
OVERVIEW_QUERY = '''
query getAccommodation($id: String!, $deviceType: String) {
business: placeDetail(input: {id: $id, isNx: true, deviceType: $deviceType}) {
base {
id
name
category
roadAddress
address
phone
virtualPhone
microReviews
conveniences
visitorReviewsTotal
}
images { images { origin url } }
cpImages(source: [ugcImage]) { images { origin url } }
}
}'''
REQUEST_HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
"Referer": "https://map.naver.com/",
"Origin": "https://map.naver.com",
"Content-Type": "application/json",
"Cookie": NAVER_COOKIES
}
class GraphQLException(Exception):
pass
class nvMapScraper():
url : str = None
scrap_type : str = None
rawdata : dict = None
image_link_list : list[str] = None
base_info : dict = None
def __init__(self, url):
self.url = url
async def parse_url(self):
if 'place' not in self.url:
if 'naver.me' in self.url:
async with aiohttp.ClientSession() as session:
async with session.get(self.url) as response:
self.url = str(response.url)
else:
raise GraphQLException("this shorten url not have place id")
try:
place_id = re.search(PLACE_PATTERN, self.url)[1]
except Exception as E:
raise GraphQLException("Cannot find place id")
return place_id
async def scrap(self):
try:
place_id = await self.parse_url()
data = await self.call_get_accomodation(place_id)
self.rawdata = data
fac_data = await self.get_facility_string(place_id)
self.rawdata['facilities'] = fac_data
self.image_link_list = [nv_image['origin'] for nv_image in data['data']['business']['images']['images']]
self.base_info = data['data']['business']['base']
self.facility_info = fac_data
self.scrap_type = "GraphQL"
except GraphQLException as G:
print (G)
print("fallback")
self.scrap_type = "Playwright"
pass # 나중에 pw 이용한 crawling으로 fallback 추가
return
async def call_get_accomodation(self, place_id):
payload = {
"operationName" : "getAccommodation",
"variables": { "id": place_id, "deviceType": "pc" },
"query": OVERVIEW_QUERY,
}
json_payload = json.dumps(payload)
async with aiohttp.ClientSession() as session:
async with session.post(GRAPHQL_URL, data=json_payload, headers=REQUEST_HEADERS) as response:
response.encoding = 'utf-8'
if response.status == 200: # 요청 성공
return await response.json() # await 주의
else: # 요청 실패
print('실패 상태 코드:', response.status)
print(response.text)
raise Exception()
async def get_facility_string(self, place_id):
url = f"https://pcmap.place.naver.com/accommodation/{place_id}/home"
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=REQUEST_HEADERS) as response:
soup = bs4.BeautifulSoup(await response.read(), 'html.parser')
c_elem = soup.find('span', 'place_blind', string='편의')
facilities = c_elem.parent.parent.find('div').string
return facilities
# url = "https://naver.me/IgJGCCic"
# scraper = nvMapScraper(url)
# asyncio.run(scraper.scrap())
# print(scraper.image_link_list)
# print(len(scraper.image_link_list))

View File

@ -9,6 +9,7 @@ dependencies = [
"aiohttp>=3.13.2",
"aiomysql>=0.3.2",
"asyncmy>=0.2.10",
"beautifulsoup4>=4.14.3",
"fastapi-cli>=0.0.16",
"fastapi[standard]>=0.125.0",
"openai>=2.13.0",

24
uv.lock
View File

@ -157,6 +157,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
]
[[package]]
name = "beautifulsoup4"
version = "4.14.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "soupsieve" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
]
[[package]]
name = "certifi"
version = "2025.11.12"
@ -751,6 +764,7 @@ dependencies = [
{ name = "aiohttp" },
{ name = "aiomysql" },
{ name = "asyncmy" },
{ name = "beautifulsoup4" },
{ name = "fastapi", extra = ["standard"] },
{ name = "fastapi-cli" },
{ name = "openai" },
@ -775,6 +789,7 @@ requires-dist = [
{ name = "aiohttp", specifier = ">=3.13.2" },
{ name = "aiomysql", specifier = ">=0.3.2" },
{ name = "asyncmy", specifier = ">=0.2.10" },
{ name = "beautifulsoup4", specifier = ">=4.14.3" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.125.0" },
{ name = "fastapi-cli", specifier = ">=0.0.16" },
{ name = "openai", specifier = ">=2.13.0" },
@ -1241,6 +1256,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "soupsieve"
version = "2.8.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/89/23/adf3796d740536d63a6fbda113d07e60c734b6ed5d3058d1e47fc0495e47/soupsieve-2.8.1.tar.gz", hash = "sha256:4cf733bc50fa805f5df4b8ef4740fc0e0fa6218cf3006269afd3f9d6d80fd350", size = 117856, upload-time = "2025-12-18T13:50:34.655Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/f3/b67d6ea49ca9154453b6d70b34ea22f3996b9fa55da105a79d8732227adc/soupsieve-2.8.1-py3-none-any.whl", hash = "sha256:a11fe2a6f3d76ab3cf2de04eb339c1be5b506a8a47f2ceb6d139803177f85434", size = 36710, upload-time = "2025-12-18T13:50:33.267Z" },
]
[[package]]
name = "sqladmin"
version = "0.22.0"