Merge branch 'main' into scraper-poc
|
|
@ -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 import FastAPI, HTTPException, Request, Response, status
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
|
# 로거 설정
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
class FastShipError(Exception):
|
class FastShipError(Exception):
|
||||||
|
|
@ -61,6 +72,194 @@ class DeliveryPartnerCapacityExceeded(FastShipError):
|
||||||
status = status.HTTP_406_NOT_ACCEPTABLE
|
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):
|
def _get_handler(status: int, detail: str):
|
||||||
# Define
|
# Define
|
||||||
def handler(request: Request, exception: Exception) -> Response:
|
def handler(request: Request, exception: Exception) -> Response:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import traceback
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
@ -6,6 +8,7 @@ from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database.session import get_session, AsyncSessionLocal
|
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.upload_blob_as_request import AzureBlobUploader
|
||||||
from app.utils.chatgpt_prompt import ChatgptService
|
from app.utils.chatgpt_prompt import ChatgptService
|
||||||
from app.utils.common import generate_task_id
|
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")
|
MEDIA_ROOT = Path("media")
|
||||||
|
|
||||||
|
|
@ -91,15 +97,56 @@ def _extract_region_from_address(road_address: str | None) -> str:
|
||||||
"description": "잘못된 URL",
|
"description": "잘못된 URL",
|
||||||
"model": ErrorResponse,
|
"model": ErrorResponse,
|
||||||
},
|
},
|
||||||
|
502: {
|
||||||
|
"description": "크롤링 실패",
|
||||||
|
"model": ErrorResponse,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
tags=["crawling"],
|
tags=["crawling"],
|
||||||
)
|
)
|
||||||
async def crawling(request_body: CrawlingRequest):
|
async def crawling(request_body: CrawlingRequest):
|
||||||
"""네이버 지도 장소 크롤링"""
|
"""네이버 지도 장소 크롤링"""
|
||||||
|
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)
|
scraper = NvMapScraper(request_body.url)
|
||||||
await scraper.scrap()
|
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
|
processed_info = None
|
||||||
marketing_analysis = None
|
marketing_analysis = None
|
||||||
|
|
||||||
|
|
@ -114,16 +161,76 @@ async def crawling(request_body: CrawlingRequest):
|
||||||
detail_region_info=road_address or "",
|
detail_region_info=road_address or "",
|
||||||
)
|
)
|
||||||
|
|
||||||
# ChatGPT를 이용한 마케팅 분석
|
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(
|
chatgpt_service = ChatgptService(
|
||||||
customer_name=customer_name,
|
customer_name=customer_name,
|
||||||
region=region,
|
region=region,
|
||||||
detail_region_info=road_address or "",
|
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()
|
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)
|
raw_response = await chatgpt_service.generate(prompt)
|
||||||
parsed = await chatgpt_service.parse_marketing_analysis(raw_response)
|
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)
|
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 {
|
return {
|
||||||
"image_list": scraper.image_link_list,
|
"image_list": scraper.image_link_list,
|
||||||
|
|
@ -274,7 +381,7 @@ async def upload_images(
|
||||||
images_json: Optional[str] = Form(
|
images_json: Optional[str] = Form(
|
||||||
default=None,
|
default=None,
|
||||||
description="외부 이미지 URL 목록 (JSON 문자열)",
|
description="외부 이미지 URL 목록 (JSON 문자열)",
|
||||||
example=IMAGES_JSON_EXAMPLE,
|
examples=[IMAGES_JSON_EXAMPLE],
|
||||||
),
|
),
|
||||||
files: Optional[list[UploadFile]] = File(
|
files: Optional[list[UploadFile]] = File(
|
||||||
default=None, description="이미지 바이너리 파일 목록"
|
default=None, description="이미지 바이너리 파일 목록"
|
||||||
|
|
@ -492,7 +599,7 @@ async def upload_images_blob(
|
||||||
images_json: Optional[str] = Form(
|
images_json: Optional[str] = Form(
|
||||||
default=None,
|
default=None,
|
||||||
description="외부 이미지 URL 목록 (JSON 문자열)",
|
description="외부 이미지 URL 목록 (JSON 문자열)",
|
||||||
example=IMAGES_JSON_EXAMPLE,
|
examples=[IMAGES_JSON_EXAMPLE],
|
||||||
),
|
),
|
||||||
files: Optional[list[UploadFile]] = File(
|
files: Optional[list[UploadFile]] = File(
|
||||||
default=None, description="이미지 바이너리 파일 목록"
|
default=None, description="이미지 바이너리 파일 목록"
|
||||||
|
|
@ -666,10 +773,21 @@ async def upload_images_blob(
|
||||||
f"saved: {len(result_images)}, "
|
f"saved: {len(result_images)}, "
|
||||||
f"elapsed: {(stage3_time - stage2_time)*1000:.1f}ms")
|
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:
|
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}")
|
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)
|
saved_count = len(result_images)
|
||||||
image_urls = [img.img_url for img in result_images]
|
image_urls = [img.img_url for img in result_images]
|
||||||
|
|
|
||||||
|
|
@ -220,15 +220,23 @@ async def generate_lyric(
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> GenerateLyricResponse:
|
) -> GenerateLyricResponse:
|
||||||
"""고객 정보를 기반으로 가사를 생성합니다. (백그라운드 처리)"""
|
"""고객 정보를 기반으로 가사를 생성합니다. (백그라운드 처리)"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
request_start = time.perf_counter()
|
||||||
task_id = request_body.task_id
|
task_id = request_body.task_id
|
||||||
|
|
||||||
|
print(f"[generate_lyric] ========== START ==========")
|
||||||
print(
|
print(
|
||||||
f"[generate_lyric] START - task_id: {task_id}, "
|
f"[generate_lyric] task_id: {task_id}, "
|
||||||
f"customer_name: {request_body.customer_name}, "
|
f"customer_name: {request_body.customer_name}, "
|
||||||
f"region: {request_body.region}"
|
f"region: {request_body.region}"
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 1. ChatGPT 서비스 초기화 및 프롬프트 생성
|
# ========== Step 1: ChatGPT 서비스 초기화 및 프롬프트 생성 ==========
|
||||||
|
step1_start = time.perf_counter()
|
||||||
|
print(f"[generate_lyric] Step 1: 서비스 초기화 및 프롬프트 생성...")
|
||||||
|
|
||||||
service = ChatgptService(
|
service = ChatgptService(
|
||||||
customer_name=request_body.customer_name,
|
customer_name=request_body.customer_name,
|
||||||
region=request_body.region,
|
region=request_body.region,
|
||||||
|
|
@ -237,7 +245,13 @@ async def generate_lyric(
|
||||||
)
|
)
|
||||||
prompt = service.build_lyrics_prompt()
|
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(
|
project = Project(
|
||||||
store_name=request_body.customer_name,
|
store_name=request_body.customer_name,
|
||||||
region=request_body.region,
|
region=request_body.region,
|
||||||
|
|
@ -248,12 +262,14 @@ async def generate_lyric(
|
||||||
session.add(project)
|
session.add(project)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(project)
|
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(
|
lyric = Lyric(
|
||||||
project_id=project.id,
|
project_id=project.id,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
|
|
@ -265,19 +281,33 @@ async def generate_lyric(
|
||||||
session.add(lyric)
|
session.add(lyric)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(lyric)
|
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(
|
background_tasks.add_task(
|
||||||
generate_lyric_background,
|
generate_lyric_background,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
language=request_body.language,
|
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. 즉시 응답 반환
|
# 5. 즉시 응답 반환
|
||||||
return GenerateLyricResponse(
|
return GenerateLyricResponse(
|
||||||
|
|
@ -289,7 +319,8 @@ async def generate_lyric(
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
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()
|
await session.rollback()
|
||||||
return GenerateLyricResponse(
|
return GenerateLyricResponse(
|
||||||
success=False,
|
success=False,
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ class SongFormData:
|
||||||
attributes: Dict[str, str] = field(default_factory=dict)
|
attributes: Dict[str, str] = field(default_factory=dict)
|
||||||
attributes_str: str = ""
|
attributes_str: str = ""
|
||||||
lyrics_ids: List[int] = field(default_factory=list)
|
lyrics_ids: List[int] = field(default_factory=list)
|
||||||
llm_model: str = "gpt-4o"
|
llm_model: str = "gpt-5-mini"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def from_form(cls, request: Request):
|
async def from_form(cls, request: Request):
|
||||||
|
|
@ -86,6 +86,6 @@ class SongFormData:
|
||||||
attributes=attributes,
|
attributes=attributes,
|
||||||
attributes_str=attributes_str,
|
attributes_str=attributes_str,
|
||||||
lyrics_ids=lyrics_ids,
|
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", ""),
|
prompts=form_data.get("prompts", ""),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -783,7 +783,7 @@ async def make_automation(request: Request, conn: Connection):
|
||||||
else "",
|
else "",
|
||||||
"attr_value": ", ".join(selected_values) if selected_values else "",
|
"attr_value": ", ".join(selected_values) if selected_values else "",
|
||||||
"ai": "ChatGPT",
|
"ai": "ChatGPT",
|
||||||
"ai_model": "gpt-4o",
|
"ai_model": "gpt-5-mini",
|
||||||
"genre": "후크송",
|
"genre": "후크송",
|
||||||
"sample_song": combined_sample_song or "없음",
|
"sample_song": combined_sample_song or "없음",
|
||||||
"result_song": final_lyrics,
|
"result_song": final_lyrics,
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,67 @@ Lyric Background Tasks
|
||||||
가사 생성 관련 백그라운드 태스크를 정의합니다.
|
가사 생성 관련 백그라운드 태스크를 정의합니다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import traceback
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
from app.database.session import BackgroundSessionLocal
|
from app.database.session import BackgroundSessionLocal
|
||||||
from app.lyric.models import Lyric
|
from app.lyric.models import Lyric
|
||||||
from app.utils.chatgpt_prompt import ChatgptService
|
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(
|
async def generate_lyric_background(
|
||||||
task_id: str,
|
task_id: str,
|
||||||
|
|
@ -23,10 +78,20 @@ async def generate_lyric_background(
|
||||||
prompt: ChatGPT에 전달할 프롬프트
|
prompt: ChatGPT에 전달할 프롬프트
|
||||||
language: 가사 언어
|
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:
|
try:
|
||||||
# ChatGPT 서비스 초기화 (프롬프트는 이미 생성되어 있음)
|
# ========== Step 1: ChatGPT 서비스 초기화 ==========
|
||||||
|
step1_start = time.perf_counter()
|
||||||
|
print(f"[generate_lyric_background] Step 1: ChatGPT 서비스 초기화...")
|
||||||
|
|
||||||
service = ChatgptService(
|
service = ChatgptService(
|
||||||
customer_name="", # 프롬프트가 이미 생성되었으므로 빈 값
|
customer_name="", # 프롬프트가 이미 생성되었으므로 빈 값
|
||||||
region="",
|
region="",
|
||||||
|
|
@ -34,65 +99,48 @@ async def generate_lyric_background(
|
||||||
language=language,
|
language=language,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ChatGPT를 통해 가사 생성
|
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||||
print(f"[generate_lyric_background] ChatGPT generation started - task_id: {task_id}")
|
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)
|
result = await service.generate(prompt=prompt)
|
||||||
print(f"[generate_lyric_background] ChatGPT generation completed - task_id: {task_id}")
|
|
||||||
|
|
||||||
# 실패 응답 검사 (ERROR 또는 ChatGPT 거부 응답)
|
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
||||||
failure_patterns = [
|
logger.info(f"[generate_lyric_background] Step 2 완료 - 응답 {len(result)}자 ({step2_elapsed:.1f}ms)")
|
||||||
"ERROR:",
|
print(f"[generate_lyric_background] Step 2 완료 - 응답 {len(result)}자 ({step2_elapsed:.1f}ms)")
|
||||||
"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
|
|
||||||
)
|
|
||||||
|
|
||||||
# Lyric 테이블 업데이트 (백그라운드 전용 세션 사용)
|
# ========== Step 3: DB 상태 업데이트 ==========
|
||||||
async with BackgroundSessionLocal() as session:
|
step3_start = time.perf_counter()
|
||||||
query_result = await session.execute(
|
print(f"[generate_lyric_background] Step 3: DB 상태 업데이트...")
|
||||||
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:
|
await _update_lyric_status(task_id, "completed", result)
|
||||||
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 session.commit()
|
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
||||||
else:
|
print(f"[generate_lyric_background] Step 3 완료 ({step3_elapsed:.1f}ms)")
|
||||||
print(f"[generate_lyric_background] Lyric NOT FOUND in DB - task_id: {task_id}")
|
|
||||||
|
# ========== 완료 ==========
|
||||||
|
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:
|
except Exception as e:
|
||||||
print(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e}")
|
elapsed = (time.perf_counter() - task_start) * 1000
|
||||||
# 실패 시 Lyric 테이블 업데이트
|
logger.error(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)")
|
||||||
async with BackgroundSessionLocal() as session:
|
print(f"[generate_lyric_background] EXCEPTION - {e} ({elapsed:.1f}ms)")
|
||||||
query_result = await session.execute(
|
traceback.print_exc()
|
||||||
select(Lyric)
|
await _update_lyric_status(task_id, "failed", f"Error: {str(e)}")
|
||||||
.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")
|
|
||||||
|
|
|
||||||
|
|
@ -335,7 +335,7 @@ class SongFormData:
|
||||||
attributes: Dict[str, str] = field(default_factory=dict)
|
attributes: Dict[str, str] = field(default_factory=dict)
|
||||||
attributes_str: str = ""
|
attributes_str: str = ""
|
||||||
lyrics_ids: List[int] = field(default_factory=list)
|
lyrics_ids: List[int] = field(default_factory=list)
|
||||||
llm_model: str = "gpt-4o"
|
llm_model: str = "gpt-5-mini"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def from_form(cls, request: Request):
|
async def from_form(cls, request: Request):
|
||||||
|
|
@ -369,6 +369,6 @@ class SongFormData:
|
||||||
attributes=attributes,
|
attributes=attributes,
|
||||||
attributes_str=attributes_str,
|
attributes_str=attributes_str,
|
||||||
lyrics_ids=lyrics_ids,
|
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", ""),
|
prompts=form_data.get("prompts", ""),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -783,7 +783,7 @@ async def make_automation(request: Request, conn: Connection):
|
||||||
else "",
|
else "",
|
||||||
"attr_value": ", ".join(selected_values) if selected_values else "",
|
"attr_value": ", ".join(selected_values) if selected_values else "",
|
||||||
"ai": "ChatGPT",
|
"ai": "ChatGPT",
|
||||||
"ai_model": "gpt-4o",
|
"ai_model": "gpt-5-mini",
|
||||||
"genre": "후크송",
|
"genre": "후크송",
|
||||||
"sample_song": combined_sample_song or "없음",
|
"sample_song": combined_sample_song or "없음",
|
||||||
"result_song": final_lyrics,
|
"result_song": final_lyrics,
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,15 @@ Song Background Tasks
|
||||||
노래 생성 관련 백그라운드 태스크를 정의합니다.
|
노래 생성 관련 백그라운드 태스크를 정의합니다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import traceback
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
import httpx
|
import httpx
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
from app.database.session import BackgroundSessionLocal
|
from app.database.session import BackgroundSessionLocal
|
||||||
from app.song.models import Song
|
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 app.utils.upload_blob_as_request import AzureBlobUploader
|
||||||
from config import prj_settings
|
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(
|
async def download_and_save_song(
|
||||||
task_id: str,
|
task_id: str,
|
||||||
|
|
@ -30,7 +127,9 @@ async def download_and_save_song(
|
||||||
audio_url: 다운로드할 오디오 URL
|
audio_url: 다운로드할 오디오 URL
|
||||||
store_name: 저장할 파일명에 사용할 업체명
|
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}")
|
print(f"[download_and_save_song] START - task_id: {task_id}, store_name: {store_name}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 저장 경로 생성: media/song/{날짜}/{uuid7}/{store_name}.mp3
|
# 저장 경로 생성: media/song/{날짜}/{uuid7}/{store_name}.mp3
|
||||||
today = date.today().strftime("%Y-%m-%d")
|
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 = Path("media") / "song" / today / unique_id
|
||||||
media_dir.mkdir(parents=True, exist_ok=True)
|
media_dir.mkdir(parents=True, exist_ok=True)
|
||||||
file_path = media_dir / file_name
|
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}")
|
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}")
|
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)
|
content = await _download_audio(audio_url, task_id)
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
async with aiofiles.open(str(file_path), "wb") as f:
|
async with aiofiles.open(str(file_path), "wb") as f:
|
||||||
await f.write(response.content)
|
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}")
|
print(f"[download_and_save_song] File saved - task_id: {task_id}, path: {file_path}")
|
||||||
|
|
||||||
# 프론트엔드에서 접근 가능한 URL 생성
|
# 프론트엔드에서 접근 가능한 URL 생성
|
||||||
relative_path = f"/media/song/{today}/{unique_id}/{file_name}"
|
relative_path = f"/media/song/{today}/{unique_id}/{file_name}"
|
||||||
base_url = f"http://{prj_settings.PROJECT_DOMAIN}"
|
base_url = f"http://{prj_settings.PROJECT_DOMAIN}"
|
||||||
file_url = f"{base_url}{relative_path}"
|
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}")
|
print(f"[download_and_save_song] URL generated - task_id: {task_id}, url: {file_url}")
|
||||||
|
|
||||||
# Song 테이블 업데이트 (새 세션 사용)
|
# Song 테이블 업데이트
|
||||||
async with BackgroundSessionLocal() as session:
|
await _update_song_status(task_id, "completed", file_url)
|
||||||
# 여러 개 있을 경우 가장 최근 것 선택
|
logger.info(f"[download_and_save_song] SUCCESS - task_id: {task_id}")
|
||||||
result = await session.execute(
|
print(f"[download_and_save_song] SUCCESS - task_id: {task_id}")
|
||||||
select(Song)
|
|
||||||
.where(Song.task_id == task_id)
|
|
||||||
.order_by(Song.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
song = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if song:
|
except httpx.HTTPError as e:
|
||||||
song.status = "completed"
|
logger.error(f"[download_and_save_song] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
|
||||||
song.song_result_url = file_url
|
print(f"[download_and_save_song] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
|
||||||
await session.commit()
|
traceback.print_exc()
|
||||||
print(f"[download_and_save_song] SUCCESS - task_id: {task_id}, status: completed")
|
await _update_song_status(task_id, "failed")
|
||||||
else:
|
|
||||||
print(f"[download_and_save_song] Song NOT FOUND in DB - task_id: {task_id}")
|
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:
|
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}")
|
print(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||||
# 실패 시 Song 테이블 업데이트
|
traceback.print_exc()
|
||||||
async with BackgroundSessionLocal() as session:
|
await _update_song_status(task_id, "failed")
|
||||||
# 여러 개 있을 경우 가장 최근 것 선택
|
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
async def download_and_upload_song_to_blob(
|
async def download_and_upload_song_to_blob(
|
||||||
|
|
@ -114,6 +203,7 @@ async def download_and_upload_song_to_blob(
|
||||||
audio_url: 다운로드할 오디오 URL
|
audio_url: 다운로드할 오디오 URL
|
||||||
store_name: 저장할 파일명에 사용할 업체명
|
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}")
|
print(f"[download_and_upload_song_to_blob] START - task_id: {task_id}, store_name: {store_name}")
|
||||||
temp_file_path: Path | None = None
|
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 = Path("media") / "temp" / task_id
|
||||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
temp_file_path = temp_dir / file_name
|
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}")
|
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}")
|
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)
|
content = await _download_audio(audio_url, task_id)
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
||||||
await f.write(response.content)
|
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}")
|
print(f"[download_and_upload_song_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}")
|
||||||
|
|
||||||
# Azure Blob Storage에 업로드
|
# Azure Blob Storage에 업로드
|
||||||
|
|
@ -150,51 +243,41 @@ async def download_and_upload_song_to_blob(
|
||||||
|
|
||||||
# SAS 토큰이 제외된 public_url 사용
|
# SAS 토큰이 제외된 public_url 사용
|
||||||
blob_url = uploader.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}")
|
print(f"[download_and_upload_song_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
|
||||||
|
|
||||||
# Song 테이블 업데이트 (새 세션 사용)
|
# Song 테이블 업데이트
|
||||||
async with BackgroundSessionLocal() as session:
|
await _update_song_status(task_id, "completed", blob_url)
|
||||||
# 여러 개 있을 경우 가장 최근 것 선택
|
logger.info(f"[download_and_upload_song_to_blob] SUCCESS - task_id: {task_id}")
|
||||||
result = await session.execute(
|
print(f"[download_and_upload_song_to_blob] SUCCESS - task_id: {task_id}")
|
||||||
select(Song)
|
|
||||||
.where(Song.task_id == task_id)
|
|
||||||
.order_by(Song.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
song = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if song:
|
except httpx.HTTPError as e:
|
||||||
song.status = "completed"
|
logger.error(f"[download_and_upload_song_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
|
||||||
song.song_result_url = blob_url
|
print(f"[download_and_upload_song_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
|
||||||
await session.commit()
|
traceback.print_exc()
|
||||||
print(f"[download_and_upload_song_to_blob] SUCCESS - task_id: {task_id}, status: completed")
|
await _update_song_status(task_id, "failed")
|
||||||
else:
|
|
||||||
print(f"[download_and_upload_song_to_blob] Song NOT FOUND in DB - task_id: {task_id}")
|
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:
|
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}")
|
print(f"[download_and_upload_song_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||||
# 실패 시 Song 테이블 업데이트
|
traceback.print_exc()
|
||||||
async with BackgroundSessionLocal() as session:
|
await _update_song_status(task_id, "failed")
|
||||||
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")
|
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# 임시 파일 삭제
|
# 임시 파일 삭제
|
||||||
if temp_file_path and temp_file_path.exists():
|
if temp_file_path and temp_file_path.exists():
|
||||||
try:
|
try:
|
||||||
temp_file_path.unlink()
|
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}")
|
print(f"[download_and_upload_song_to_blob] Temp file deleted - path: {temp_file_path}")
|
||||||
except Exception as e:
|
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}")
|
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: 저장할 파일명에 사용할 업체명
|
store_name: 저장할 파일명에 사용할 업체명
|
||||||
duration: 노래 재생 시간 (초)
|
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}")
|
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
|
temp_file_path: Path | None = None
|
||||||
task_id: str | 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()
|
song = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not song:
|
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}")
|
print(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND - suno_task_id: {suno_task_id}")
|
||||||
return
|
return
|
||||||
|
|
||||||
task_id = song.task_id
|
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}")
|
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 = Path("media") / "temp" / task_id
|
||||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
temp_file_path = temp_dir / file_name
|
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}")
|
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}")
|
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)
|
content = await _download_audio(audio_url, task_id)
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
||||||
await f.write(response.content)
|
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}")
|
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에 업로드
|
# Azure Blob Storage에 업로드
|
||||||
|
|
@ -274,53 +363,50 @@ async def download_and_upload_song_by_suno_task_id(
|
||||||
|
|
||||||
# SAS 토큰이 제외된 public_url 사용
|
# SAS 토큰이 제외된 public_url 사용
|
||||||
blob_url = uploader.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}")
|
print(f"[download_and_upload_song_by_suno_task_id] Uploaded to Blob - suno_task_id: {suno_task_id}, url: {blob_url}")
|
||||||
|
|
||||||
# Song 테이블 업데이트 (새 세션 사용)
|
# Song 테이블 업데이트
|
||||||
async with BackgroundSessionLocal() as session:
|
await _update_song_status(
|
||||||
result = await session.execute(
|
task_id=task_id,
|
||||||
select(Song)
|
status="completed",
|
||||||
.where(Song.suno_task_id == suno_task_id)
|
song_url=blob_url,
|
||||||
.order_by(Song.created_at.desc())
|
suno_task_id=suno_task_id,
|
||||||
.limit(1)
|
duration=duration,
|
||||||
)
|
)
|
||||||
song = result.scalar_one_or_none()
|
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:
|
except httpx.HTTPError as e:
|
||||||
song.status = "completed"
|
logger.error(f"[download_and_upload_song_by_suno_task_id] DOWNLOAD ERROR - suno_task_id: {suno_task_id}, error: {e}")
|
||||||
song.song_result_url = blob_url
|
print(f"[download_and_upload_song_by_suno_task_id] DOWNLOAD ERROR - suno_task_id: {suno_task_id}, error: {e}")
|
||||||
if duration is not None:
|
traceback.print_exc()
|
||||||
song.duration = duration
|
if task_id:
|
||||||
await session.commit()
|
await _update_song_status(task_id, "failed", suno_task_id=suno_task_id)
|
||||||
print(f"[download_and_upload_song_by_suno_task_id] SUCCESS - suno_task_id: {suno_task_id}, status: completed, duration: {duration}")
|
|
||||||
else:
|
except SQLAlchemyError as e:
|
||||||
print(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND in DB - suno_task_id: {suno_task_id}")
|
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:
|
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}")
|
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:
|
if task_id:
|
||||||
async with BackgroundSessionLocal() as session:
|
await _update_song_status(task_id, "failed", suno_task_id=suno_task_id)
|
||||||
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")
|
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# 임시 파일 삭제
|
# 임시 파일 삭제
|
||||||
if temp_file_path and temp_file_path.exists():
|
if temp_file_path and temp_file_path.exists():
|
||||||
try:
|
try:
|
||||||
temp_file_path.unlink()
|
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}")
|
print(f"[download_and_upload_song_by_suno_task_id] Temp file deleted - path: {temp_file_path}")
|
||||||
except Exception as e:
|
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}")
|
print(f"[download_and_upload_song_by_suno_task_id] Failed to delete temp file: {e}")
|
||||||
|
|
||||||
# 임시 디렉토리 삭제 시도
|
# 임시 디렉토리 삭제 시도
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from openai import AsyncOpenAI
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
from config import apikey_settings
|
from config import apikey_settings
|
||||||
|
|
||||||
|
# 로거 설정
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# fmt: off
|
# fmt: off
|
||||||
LYRICS_PROMPT_TEMPLATE_ORI = """
|
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
|
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"
|
- Return as JSON with key "tags"
|
||||||
- **MUST be written in Korean (한국어)**
|
- **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]
|
[CRITICAL LANGUAGE REQUIREMENT - ABSOLUTE RULE]
|
||||||
ALL OUTPUT MUST BE WRITTEN IN KOREAN (한국어)
|
ALL OUTPUT MUST BE WRITTEN IN KOREAN (한국어)
|
||||||
- Analysis sections: Korean only
|
- Analysis sections: Korean only
|
||||||
- Tags: Korean only
|
- Tags: Korean only
|
||||||
- Facilities: Korean only
|
|
||||||
- This is a NON-NEGOTIABLE requirement
|
- This is a NON-NEGOTIABLE requirement
|
||||||
- Any output in English or other languages is considered a FAILURE
|
- Any output in English or other languages is considered a FAILURE
|
||||||
- Violation of this rule invalidates the entire response
|
- Violation of this rule invalidates the entire response
|
||||||
|
|
@ -199,8 +195,7 @@ ALL OUTPUT MUST BE WRITTEN IN KOREAN (한국어)
|
||||||
## JSON Data
|
## JSON Data
|
||||||
```json
|
```json
|
||||||
{{
|
{{
|
||||||
"tags": ["태그1", "태그2", "태그3", "태그4", "태그5"],
|
"tags": ["태그1", "태그2", "태그3", "태그4", "태그5"]
|
||||||
"facilities": ["부대시설1", "부대시설2", "부대시설3", "부대시설4", "부대시설5"]
|
|
||||||
}}
|
}}
|
||||||
```
|
```
|
||||||
---
|
---
|
||||||
|
|
@ -215,6 +210,11 @@ ERROR: [Brief reason for failure in English]
|
||||||
|
|
||||||
|
|
||||||
class ChatgptService:
|
class ChatgptService:
|
||||||
|
"""ChatGPT API 서비스 클래스
|
||||||
|
|
||||||
|
GPT 5.0 모델을 사용하여 마케팅 가사 및 분석을 생성합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
customer_name: str,
|
customer_name: str,
|
||||||
|
|
@ -222,9 +222,8 @@ class ChatgptService:
|
||||||
detail_region_info: str = "",
|
detail_region_info: str = "",
|
||||||
language: str = "Korean",
|
language: str = "Korean",
|
||||||
):
|
):
|
||||||
# 최신 모델: GPT-5, GPT-5 mini, GPT-5 nano, GPT-4.1, GPT-4.1 mini, GPT-4.1 nano
|
# 최신 모델: gpt-5-mini
|
||||||
# 이전 세대: GPT-4o, GPT-4o mini, GPT-4 Turbo, GPT-3.5 Turbo
|
self.model = "gpt-5-mini"
|
||||||
self.model = "gpt-4o"
|
|
||||||
self.client = AsyncOpenAI(api_key=apikey_settings.CHATGPT_API_KEY)
|
self.client = AsyncOpenAI(api_key=apikey_settings.CHATGPT_API_KEY)
|
||||||
self.customer_name = customer_name
|
self.customer_name = customer_name
|
||||||
self.region = region
|
self.region = region
|
||||||
|
|
@ -248,44 +247,102 @@ class ChatgptService:
|
||||||
detail_region_info=self.detail_region_info,
|
detail_region_info=self.detail_region_info,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def generate(self, prompt: str | None = None) -> str:
|
async def _call_gpt_api(self, prompt: str) -> str:
|
||||||
"""GPT에게 프롬프트를 전달하여 결과를 반환"""
|
"""GPT API를 직접 호출합니다 (내부 메서드).
|
||||||
if prompt is None:
|
|
||||||
prompt = self.build_lyrics_prompt()
|
Args:
|
||||||
print("Generated Prompt: ", prompt)
|
prompt: GPT에 전달할 프롬프트
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
GPT 응답 문자열
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
APIError, APIConnectionError, RateLimitError: OpenAI API 오류
|
||||||
|
"""
|
||||||
completion = await self.client.chat.completions.create(
|
completion = await self.client.chat.completions.create(
|
||||||
model=self.model, messages=[{"role": "user", "content": prompt}]
|
model=self.model, messages=[{"role": "user", "content": prompt}]
|
||||||
)
|
)
|
||||||
message = completion.choices[0].message.content
|
message = completion.choices[0].message.content
|
||||||
return message or ""
|
return message or ""
|
||||||
|
|
||||||
async def summarize_marketing(self, text: str) -> str:
|
async def generate(
|
||||||
"""마케팅 텍스트를 항목으로 구분하여 500자로 요약 정리"""
|
self,
|
||||||
prompt = f"""[ROLE]
|
prompt: str | None = None,
|
||||||
마케팅 콘텐츠 요약 전문가
|
) -> str:
|
||||||
|
"""GPT에게 프롬프트를 전달하여 결과를 반환합니다.
|
||||||
|
|
||||||
[INPUT]
|
Args:
|
||||||
{text}
|
prompt: GPT에 전달할 프롬프트 (None이면 기본 가사 프롬프트 사용)
|
||||||
|
|
||||||
[TASK]
|
Returns:
|
||||||
위 텍스트를 분석하여 핵심 내용을 항목별로 구분하여 500자 이내로 요약해주세요.
|
GPT 응답 문자열
|
||||||
|
|
||||||
[OUTPUT REQUIREMENTS]
|
Raises:
|
||||||
- 항목별로 구분하여 정리 (예: 타겟 고객, 차별점, 지역 특성 등)
|
APIError, APIConnectionError, RateLimitError: OpenAI API 오류
|
||||||
- 총 500자 이내로 요약
|
|
||||||
- 핵심 정보만 간결하게 포함
|
|
||||||
- 한국어로 작성
|
|
||||||
|
|
||||||
[OUTPUT FORMAT]
|
|
||||||
---
|
|
||||||
[항목별로 구분된 500자 이내 요약]
|
|
||||||
---
|
|
||||||
"""
|
"""
|
||||||
completion = await self.client.chat.completions.create(
|
if prompt is None:
|
||||||
model=self.model, messages=[{"role": "user", "content": prompt}]
|
prompt = self.build_lyrics_prompt()
|
||||||
)
|
|
||||||
message = completion.choices[0].message.content
|
print(f"[ChatgptService] Generated Prompt (length: {len(prompt)})")
|
||||||
result = message or ""
|
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자로 요약 정리.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: 요약할 마케팅 텍스트
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
요약된 텍스트
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
APIError, APIConnectionError, RateLimitError: OpenAI API 오류
|
||||||
|
"""
|
||||||
|
prompt = f"""[ROLE]
|
||||||
|
마케팅 콘텐츠 요약 전문가
|
||||||
|
|
||||||
|
[INPUT]
|
||||||
|
{text}
|
||||||
|
|
||||||
|
[TASK]
|
||||||
|
위 텍스트를 분석하여 핵심 내용을 항목별로 구분하여 500자 이내로 요약해주세요.
|
||||||
|
|
||||||
|
[OUTPUT REQUIREMENTS]
|
||||||
|
- 5개 항목으로 구분: 타겟 고객, 핵심 차별점, 지역 특성, 시즌별 포인트, 추천 키워드
|
||||||
|
- 각 항목은 줄바꿈으로 구분
|
||||||
|
- 총 500자 이내로 요약
|
||||||
|
- 핵심 정보만 간결하게 포함
|
||||||
|
- 한국어로 작성
|
||||||
|
- 특수문자 사용 금지 (괄호, 슬래시, 하이픈, 물결표 등 제외)
|
||||||
|
- 쉼표와 마침표만 사용하여 자연스러운 문장으로 작성
|
||||||
|
|
||||||
|
[OUTPUT FORMAT - 반드시 아래 형식 준수]
|
||||||
|
---
|
||||||
|
타겟 고객
|
||||||
|
[대상 고객층을 자연스러운 문장으로 설명]
|
||||||
|
|
||||||
|
핵심 차별점
|
||||||
|
[숙소의 차별화 포인트를 자연스러운 문장으로 설명]
|
||||||
|
|
||||||
|
지역 특성
|
||||||
|
[주변 관광지와 지역 특색을 자연스러운 문장으로 설명]
|
||||||
|
|
||||||
|
시즌별 포인트
|
||||||
|
[계절별 매력 포인트를 자연스러운 문장으로 설명]
|
||||||
|
|
||||||
|
추천 키워드
|
||||||
|
[마케팅에 활용할 키워드를 쉼표로 구분하여 나열]
|
||||||
|
---
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = await self.generate(prompt=prompt)
|
||||||
|
|
||||||
# --- 구분자 제거
|
# --- 구분자 제거
|
||||||
if result.startswith("---"):
|
if result.startswith("---"):
|
||||||
|
|
@ -295,9 +352,15 @@ class ChatgptService:
|
||||||
|
|
||||||
return result
|
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 마케팅 분석 응답을 파싱하고 요약하여 딕셔너리로 반환
|
"""ChatGPT 마케팅 분석 응답을 파싱하고 요약하여 딕셔너리로 반환
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw_response: ChatGPT 마케팅 분석 응답 원문
|
||||||
|
facility_info: 크롤링에서 가져온 편의시설 정보 문자열
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: {"report": str, "tags": list[str], "facilities": list[str]}
|
dict: {"report": str, "tags": list[str], "facilities": list[str]}
|
||||||
"""
|
"""
|
||||||
|
|
@ -311,7 +374,7 @@ class ChatgptService:
|
||||||
try:
|
try:
|
||||||
json_data = json.loads(json_match.group(1))
|
json_data = json.loads(json_match.group(1))
|
||||||
tags = json_data.get("tags", [])
|
tags = json_data.get("tags", [])
|
||||||
facilities = json_data.get("facilities", [])
|
print(f"[parse_marketing_analysis] GPT 응답에서 tags 파싱 완료: {tags}")
|
||||||
# JSON 블록을 제외한 리포트 부분 추출
|
# JSON 블록을 제외한 리포트 부분 추출
|
||||||
report = raw_response[: json_match.start()].strip()
|
report = raw_response[: json_match.start()].strip()
|
||||||
# --- 구분자 제거
|
# --- 구분자 제거
|
||||||
|
|
@ -320,10 +383,22 @@ class ChatgptService:
|
||||||
if report.endswith("---"):
|
if report.endswith("---"):
|
||||||
report = report[:-3].strip()
|
report = report[:-3].strip()
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
|
print("[parse_marketing_analysis] JSON 파싱 실패")
|
||||||
pass
|
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자로 요약
|
# 리포트 내용을 500자로 요약
|
||||||
if report:
|
if report:
|
||||||
report = await self.summarize_marketing(report)
|
report = await self.summarize_marketing(report)
|
||||||
|
|
||||||
|
print(f"[parse_marketing_analysis] 최종 facilities: {facilities}")
|
||||||
return {"report": report, "tags": tags, "facilities": facilities}
|
return {"report": report, "tags": tags, "facilities": facilities}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ response = await creatomate.make_creatomate_call(template_id, modifications)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
|
|
@ -37,6 +38,9 @@ import httpx
|
||||||
|
|
||||||
from config import apikey_settings, creatomate_settings
|
from config import apikey_settings, creatomate_settings
|
||||||
|
|
||||||
|
# 로거 설정
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# Orientation 타입 정의
|
# Orientation 타입 정의
|
||||||
OrientationType = Literal["horizontal", "vertical"]
|
OrientationType = Literal["horizontal", "vertical"]
|
||||||
|
|
@ -138,11 +142,51 @@ class CreatomateService:
|
||||||
"Authorization": f"Bearer {self.api_key}",
|
"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:
|
async def get_all_templates_data(self) -> dict:
|
||||||
"""모든 템플릿 정보를 조회합니다."""
|
"""모든 템플릿 정보를 조회합니다."""
|
||||||
url = f"{self.BASE_URL}/v1/templates"
|
url = f"{self.BASE_URL}/v1/templates"
|
||||||
client = await get_shared_client()
|
response = await self._request("GET", url, timeout=30.0)
|
||||||
response = await client.get(url, headers=self.headers, timeout=30.0)
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
|
@ -175,8 +219,7 @@ class CreatomateService:
|
||||||
|
|
||||||
# API 호출
|
# API 호출
|
||||||
url = f"{self.BASE_URL}/v1/templates/{template_id}"
|
url = f"{self.BASE_URL}/v1/templates/{template_id}"
|
||||||
client = await get_shared_client()
|
response = await self._request("GET", url, timeout=30.0)
|
||||||
response = await client.get(url, headers=self.headers, timeout=30.0)
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
|
|
@ -331,10 +374,7 @@ class CreatomateService:
|
||||||
"template_id": template_id,
|
"template_id": template_id,
|
||||||
"modifications": modifications,
|
"modifications": modifications,
|
||||||
}
|
}
|
||||||
client = await get_shared_client()
|
response = await self._request("POST", url, timeout=60.0, json=data)
|
||||||
response = await client.post(
|
|
||||||
url, json=data, headers=self.headers, timeout=60.0
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
|
@ -345,10 +385,7 @@ class CreatomateService:
|
||||||
response에 요청 정보가 있으니 폴링 필요
|
response에 요청 정보가 있으니 폴링 필요
|
||||||
"""
|
"""
|
||||||
url = f"{self.BASE_URL}/v2/renders"
|
url = f"{self.BASE_URL}/v2/renders"
|
||||||
client = await get_shared_client()
|
response = await self._request("POST", url, timeout=60.0, json=source)
|
||||||
response = await client.post(
|
|
||||||
url, json=source, headers=self.headers, timeout=60.0
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
|
@ -379,8 +416,7 @@ class CreatomateService:
|
||||||
- failed: 실패
|
- failed: 실패
|
||||||
"""
|
"""
|
||||||
url = f"{self.BASE_URL}/v1/renders/{render_id}"
|
url = f"{self.BASE_URL}/v1/renders/{render_id}"
|
||||||
client = await get_shared_client()
|
response = await self._request("GET", url, timeout=30.0)
|
||||||
response = await client.get(url, headers=self.headers, timeout=30.0)
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -1,17 +1,35 @@
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
import bs4
|
||||||
|
|
||||||
from config import crawler_settings
|
from config import crawler_settings
|
||||||
|
|
||||||
|
# 로거 설정
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class GraphQLException(Exception):
|
class GraphQLException(Exception):
|
||||||
|
"""GraphQL 요청 실패 시 발생하는 예외"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CrawlingTimeoutException(Exception):
|
||||||
|
"""크롤링 타임아웃 시 발생하는 예외"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class NvMapScraper:
|
class NvMapScraper:
|
||||||
|
"""네이버 지도 GraphQL API 스크래퍼
|
||||||
|
|
||||||
|
네이버 지도에서 숙소/장소 정보를 크롤링합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
GRAPHQL_URL: str = "https://pcmap-api.place.naver.com/graphql"
|
GRAPHQL_URL: str = "https://pcmap-api.place.naver.com/graphql"
|
||||||
|
REQUEST_TIMEOUT = 120 # 초
|
||||||
|
|
||||||
OVERVIEW_QUERY: str = """
|
OVERVIEW_QUERY: str = """
|
||||||
query getAccommodation($id: String!, $deviceType: String) {
|
query getAccommodation($id: String!, $deviceType: String) {
|
||||||
|
|
@ -49,6 +67,7 @@ query getAccommodation($id: String!, $deviceType: String) {
|
||||||
self.rawdata: dict | None = None
|
self.rawdata: dict | None = None
|
||||||
self.image_link_list: list[str] | None = None
|
self.image_link_list: list[str] | None = None
|
||||||
self.base_info: dict | None = None
|
self.base_info: dict | None = None
|
||||||
|
self.facility_info: str | None = None
|
||||||
|
|
||||||
def _get_request_headers(self) -> dict:
|
def _get_request_headers(self) -> dict:
|
||||||
headers = self.DEFAULT_HEADERS.copy()
|
headers = self.DEFAULT_HEADERS.copy()
|
||||||
|
|
@ -56,8 +75,19 @@ query getAccommodation($id: String!, $deviceType: String) {
|
||||||
headers["Cookie"] = self.cookies
|
headers["Cookie"] = self.cookies
|
||||||
return headers
|
return headers
|
||||||
|
|
||||||
def parse_url(self) -> str:
|
async def parse_url(self) -> str:
|
||||||
|
"""URL에서 place ID를 추출합니다. 단축 URL인 경우 실제 URL로 변환합니다."""
|
||||||
place_pattern = r"/place/(\d+)"
|
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)
|
match = re.search(place_pattern, self.url)
|
||||||
if not match:
|
if not match:
|
||||||
raise GraphQLException("Failed to parse place ID from URL")
|
raise GraphQLException("Failed to parse place ID from URL")
|
||||||
|
|
@ -65,14 +95,17 @@ query getAccommodation($id: String!, $deviceType: String) {
|
||||||
|
|
||||||
async def scrap(self):
|
async def scrap(self):
|
||||||
try:
|
try:
|
||||||
place_id = self.parse_url()
|
place_id = await self.parse_url()
|
||||||
data = await self._call_get_accommodation(place_id)
|
data = await self._call_get_accommodation(place_id)
|
||||||
self.rawdata = data
|
self.rawdata = data
|
||||||
|
fac_data = await self._get_facility_string(place_id)
|
||||||
|
self.rawdata["facilities"] = fac_data
|
||||||
self.image_link_list = [
|
self.image_link_list = [
|
||||||
nv_image["origin"]
|
nv_image["origin"]
|
||||||
for nv_image in data["data"]["business"]["images"]["images"]
|
for nv_image in data["data"]["business"]["images"]["images"]
|
||||||
]
|
]
|
||||||
self.base_info = data["data"]["business"]["base"]
|
self.base_info = data["data"]["business"]["base"]
|
||||||
|
self.facility_info = fac_data
|
||||||
self.scrap_type = "GraphQL"
|
self.scrap_type = "GraphQL"
|
||||||
|
|
||||||
except GraphQLException:
|
except GraphQLException:
|
||||||
|
|
@ -83,25 +116,81 @@ query getAccommodation($id: String!, $deviceType: String) {
|
||||||
return
|
return
|
||||||
|
|
||||||
async def _call_get_accommodation(self, place_id: str) -> dict:
|
async def _call_get_accommodation(self, place_id: str) -> dict:
|
||||||
|
"""GraphQL API를 호출하여 숙소 정보를 가져옵니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
place_id: 네이버 지도 장소 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
GraphQL 응답 데이터
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
GraphQLException: API 호출 실패 시
|
||||||
|
CrawlingTimeoutException: 타임아웃 발생 시
|
||||||
|
"""
|
||||||
payload = {
|
payload = {
|
||||||
"operationName": "getAccommodation",
|
"operationName": "getAccommodation",
|
||||||
"variables": {"id": place_id, "deviceType": "pc"},
|
"variables": {"id": place_id, "deviceType": "pc"},
|
||||||
"query": self.OVERVIEW_QUERY,
|
"query": self.OVERVIEW_QUERY,
|
||||||
}
|
}
|
||||||
json_payload = json.dumps(payload)
|
json_payload = json.dumps(payload)
|
||||||
|
timeout = aiohttp.ClientTimeout(total=self.REQUEST_TIMEOUT)
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
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(
|
async with session.post(
|
||||||
self.GRAPHQL_URL, data=json_payload, headers=self._get_request_headers()
|
self.GRAPHQL_URL,
|
||||||
|
data=json_payload,
|
||||||
|
headers=self._get_request_headers()
|
||||||
) as response:
|
) as response:
|
||||||
if response.status == 200:
|
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()
|
return await response.json()
|
||||||
else:
|
|
||||||
print("실패 상태 코드:", response.status)
|
# 실패 상태 코드
|
||||||
|
logger.error(f"[NvMapScraper] Failed with status {response.status} - place_id: {place_id}")
|
||||||
|
print(f"[NvMapScraper] 실패 상태 코드: {response.status}")
|
||||||
raise GraphQLException(
|
raise GraphQLException(
|
||||||
f"Request failed with status {response.status}"
|
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__":
|
# if __name__ == "__main__":
|
||||||
# import asyncio
|
# import asyncio
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ URL 경로 형식:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
@ -40,6 +41,8 @@ import httpx
|
||||||
|
|
||||||
from config import azure_blob_settings
|
from config import azure_blob_settings
|
||||||
|
|
||||||
|
# 로거 설정
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 모듈 레벨 공유 HTTP 클라이언트 (싱글톤 패턴)
|
# 모듈 레벨 공유 HTTP 클라이언트 (싱글톤 패턴)
|
||||||
|
|
@ -138,17 +141,30 @@ class AzureBlobUploader:
|
||||||
timeout: float,
|
timeout: float,
|
||||||
log_prefix: str,
|
log_prefix: str,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""바이트 데이터를 업로드하는 공통 내부 메서드"""
|
"""바이트 데이터를 업로드하는 공통 내부 메서드
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_content: 업로드할 바이트 데이터
|
||||||
|
upload_url: 업로드 URL
|
||||||
|
headers: HTTP 헤더
|
||||||
|
timeout: 요청 타임아웃 (초)
|
||||||
|
log_prefix: 로그 접두사
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 업로드 성공 여부
|
||||||
|
"""
|
||||||
|
size = len(file_content)
|
||||||
start_time = time.perf_counter()
|
start_time = time.perf_counter()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
logger.info(f"[{log_prefix}] Starting upload")
|
||||||
print(f"[{log_prefix}] Getting shared client...")
|
print(f"[{log_prefix}] Getting shared client...")
|
||||||
|
|
||||||
client = await get_shared_blob_client()
|
client = await get_shared_blob_client()
|
||||||
client_time = time.perf_counter()
|
client_time = time.perf_counter()
|
||||||
elapsed_ms = (client_time - start_time) * 1000
|
elapsed_ms = (client_time - start_time) * 1000
|
||||||
print(f"[{log_prefix}] Client acquired in {elapsed_ms:.1f}ms")
|
print(f"[{log_prefix}] Client acquired in {elapsed_ms:.1f}ms")
|
||||||
|
|
||||||
size = len(file_content)
|
|
||||||
print(f"[{log_prefix}] Starting upload... "
|
print(f"[{log_prefix}] Starting upload... "
|
||||||
f"(size: {size} bytes, timeout: {timeout}s)")
|
f"(size: {size} bytes, timeout: {timeout}s)")
|
||||||
|
|
||||||
|
|
@ -160,11 +176,14 @@ class AzureBlobUploader:
|
||||||
duration_ms = (upload_time - start_time) * 1000
|
duration_ms = (upload_time - start_time) * 1000
|
||||||
|
|
||||||
if response.status_code in [200, 201]:
|
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}, "
|
print(f"[{log_prefix}] SUCCESS - Status: {response.status_code}, "
|
||||||
f"Duration: {duration_ms:.1f}ms")
|
f"Duration: {duration_ms:.1f}ms")
|
||||||
print(f"[{log_prefix}] Public URL: {self._last_public_url}")
|
print(f"[{log_prefix}] Public URL: {self._last_public_url}")
|
||||||
return True
|
return True
|
||||||
else:
|
|
||||||
|
# 업로드 실패
|
||||||
|
logger.error(f"[{log_prefix}] FAILED - Status: {response.status_code}")
|
||||||
print(f"[{log_prefix}] FAILED - Status: {response.status_code}, "
|
print(f"[{log_prefix}] FAILED - Status: {response.status_code}, "
|
||||||
f"Duration: {duration_ms:.1f}ms")
|
f"Duration: {duration_ms:.1f}ms")
|
||||||
print(f"[{log_prefix}] Response: {response.text[:500]}")
|
print(f"[{log_prefix}] Response: {response.text[:500]}")
|
||||||
|
|
@ -172,21 +191,27 @@ class AzureBlobUploader:
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
elapsed = time.perf_counter() - start_time
|
elapsed = time.perf_counter() - start_time
|
||||||
print(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s "
|
logger.error(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s")
|
||||||
f"(limit: {timeout}s)")
|
print(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except httpx.ConnectError as e:
|
except httpx.ConnectError as e:
|
||||||
elapsed = time.perf_counter() - start_time
|
elapsed = time.perf_counter() - start_time
|
||||||
|
logger.error(f"[{log_prefix}] CONNECT_ERROR: {e}")
|
||||||
print(f"[{log_prefix}] CONNECT_ERROR after {elapsed:.1f}s - "
|
print(f"[{log_prefix}] CONNECT_ERROR after {elapsed:.1f}s - "
|
||||||
f"{type(e).__name__}: {e}")
|
f"{type(e).__name__}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except httpx.ReadError as e:
|
except httpx.ReadError as e:
|
||||||
elapsed = time.perf_counter() - start_time
|
elapsed = time.perf_counter() - start_time
|
||||||
|
logger.error(f"[{log_prefix}] READ_ERROR: {e}")
|
||||||
print(f"[{log_prefix}] READ_ERROR after {elapsed:.1f}s - "
|
print(f"[{log_prefix}] READ_ERROR after {elapsed:.1f}s - "
|
||||||
f"{type(e).__name__}: {e}")
|
f"{type(e).__name__}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
elapsed = time.perf_counter() - start_time
|
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 - "
|
print(f"[{log_prefix}] ERROR after {elapsed:.1f}s - "
|
||||||
f"{type(e).__name__}: {e}")
|
f"{type(e).__name__}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
|
||||||
|
|
@ -783,7 +783,7 @@ async def make_automation(request: Request, conn: Connection):
|
||||||
else "",
|
else "",
|
||||||
"attr_value": ", ".join(selected_values) if selected_values else "",
|
"attr_value": ", ".join(selected_values) if selected_values else "",
|
||||||
"ai": "ChatGPT",
|
"ai": "ChatGPT",
|
||||||
"ai_model": "gpt-4o",
|
"ai_model": "gpt-5-mini",
|
||||||
"genre": "후크송",
|
"genre": "후크송",
|
||||||
"sample_song": combined_sample_song or "없음",
|
"sample_song": combined_sample_song or "없음",
|
||||||
"result_song": final_lyrics,
|
"result_song": final_lyrics,
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,109 @@ Video Background Tasks
|
||||||
영상 생성 관련 백그라운드 태스크를 정의합니다.
|
영상 생성 관련 백그라운드 태스크를 정의합니다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import traceback
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
import httpx
|
import httpx
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
from app.database.session import BackgroundSessionLocal
|
from app.database.session import BackgroundSessionLocal
|
||||||
from app.video.models import Video
|
from app.video.models import Video
|
||||||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
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(
|
async def download_and_upload_video_to_blob(
|
||||||
task_id: str,
|
task_id: str,
|
||||||
|
|
@ -27,6 +120,7 @@ async def download_and_upload_video_to_blob(
|
||||||
video_url: 다운로드할 영상 URL
|
video_url: 다운로드할 영상 URL
|
||||||
store_name: 저장할 파일명에 사용할 업체명
|
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}")
|
print(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, store_name: {store_name}")
|
||||||
temp_file_path: Path | None = None
|
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 = Path("media") / "temp" / task_id
|
||||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
temp_file_path = temp_dir / file_name
|
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}")
|
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}")
|
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)
|
content = await _download_video(video_url, task_id)
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
||||||
await f.write(response.content)
|
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}")
|
print(f"[download_and_upload_video_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}")
|
||||||
|
|
||||||
# Azure Blob Storage에 업로드
|
# Azure Blob Storage에 업로드
|
||||||
|
|
@ -63,51 +160,41 @@ async def download_and_upload_video_to_blob(
|
||||||
|
|
||||||
# SAS 토큰이 제외된 public_url 사용
|
# SAS 토큰이 제외된 public_url 사용
|
||||||
blob_url = uploader.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}")
|
print(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
|
||||||
|
|
||||||
# Video 테이블 업데이트 (새 세션 사용)
|
# Video 테이블 업데이트
|
||||||
async with BackgroundSessionLocal() as session:
|
await _update_video_status(task_id, "completed", blob_url)
|
||||||
# 여러 개 있을 경우 가장 최근 것 선택
|
logger.info(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}")
|
||||||
result = await session.execute(
|
print(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}")
|
||||||
select(Video)
|
|
||||||
.where(Video.task_id == task_id)
|
|
||||||
.order_by(Video.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
video = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if video:
|
except httpx.HTTPError as e:
|
||||||
video.status = "completed"
|
logger.error(f"[download_and_upload_video_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
|
||||||
video.result_movie_url = blob_url
|
print(f"[download_and_upload_video_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
|
||||||
await session.commit()
|
traceback.print_exc()
|
||||||
print(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}, status: completed")
|
await _update_video_status(task_id, "failed")
|
||||||
else:
|
|
||||||
print(f"[download_and_upload_video_to_blob] Video NOT FOUND in DB - task_id: {task_id}")
|
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:
|
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}")
|
print(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||||
# 실패 시 Video 테이블 업데이트
|
traceback.print_exc()
|
||||||
async with BackgroundSessionLocal() as session:
|
await _update_video_status(task_id, "failed")
|
||||||
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")
|
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# 임시 파일 삭제
|
# 임시 파일 삭제
|
||||||
if temp_file_path and temp_file_path.exists():
|
if temp_file_path and temp_file_path.exists():
|
||||||
try:
|
try:
|
||||||
temp_file_path.unlink()
|
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}")
|
print(f"[download_and_upload_video_to_blob] Temp file deleted - path: {temp_file_path}")
|
||||||
except Exception as e:
|
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}")
|
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
|
video_url: 다운로드할 영상 URL
|
||||||
store_name: 저장할 파일명에 사용할 업체명
|
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}")
|
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
|
temp_file_path: Path | None = None
|
||||||
task_id: str | 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()
|
video = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not video:
|
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}")
|
print(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND - creatomate_render_id: {creatomate_render_id}")
|
||||||
return
|
return
|
||||||
|
|
||||||
task_id = video.task_id
|
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}")
|
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 = Path("media") / "temp" / task_id
|
||||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
temp_file_path = temp_dir / file_name
|
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}")
|
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}")
|
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)
|
content = await _download_video(video_url, task_id)
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
async with aiofiles.open(str(temp_file_path), "wb") as f:
|
||||||
await f.write(response.content)
|
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}")
|
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에 업로드
|
# Azure Blob Storage에 업로드
|
||||||
|
|
@ -185,51 +278,49 @@ async def download_and_upload_video_by_creatomate_render_id(
|
||||||
|
|
||||||
# SAS 토큰이 제외된 public_url 사용
|
# SAS 토큰이 제외된 public_url 사용
|
||||||
blob_url = uploader.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}")
|
print(f"[download_and_upload_video_by_creatomate_render_id] Uploaded to Blob - creatomate_render_id: {creatomate_render_id}, url: {blob_url}")
|
||||||
|
|
||||||
# Video 테이블 업데이트 (새 세션 사용)
|
# Video 테이블 업데이트
|
||||||
async with BackgroundSessionLocal() as session:
|
await _update_video_status(
|
||||||
result = await session.execute(
|
task_id=task_id,
|
||||||
select(Video)
|
status="completed",
|
||||||
.where(Video.creatomate_render_id == creatomate_render_id)
|
video_url=blob_url,
|
||||||
.order_by(Video.created_at.desc())
|
creatomate_render_id=creatomate_render_id,
|
||||||
.limit(1)
|
|
||||||
)
|
)
|
||||||
video = result.scalar_one_or_none()
|
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:
|
except httpx.HTTPError as e:
|
||||||
video.status = "completed"
|
logger.error(f"[download_and_upload_video_by_creatomate_render_id] DOWNLOAD ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}")
|
||||||
video.result_movie_url = blob_url
|
print(f"[download_and_upload_video_by_creatomate_render_id] DOWNLOAD ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}")
|
||||||
await session.commit()
|
traceback.print_exc()
|
||||||
print(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}, status: completed")
|
if task_id:
|
||||||
else:
|
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
|
||||||
print(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND in DB - 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:
|
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}")
|
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:
|
if task_id:
|
||||||
async with BackgroundSessionLocal() as session:
|
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
|
||||||
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")
|
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# 임시 파일 삭제
|
# 임시 파일 삭제
|
||||||
if temp_file_path and temp_file_path.exists():
|
if temp_file_path and temp_file_path.exists():
|
||||||
try:
|
try:
|
||||||
temp_file_path.unlink()
|
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}")
|
print(f"[download_and_upload_video_by_creatomate_render_id] Temp file deleted - path: {temp_file_path}")
|
||||||
except Exception as e:
|
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}")
|
print(f"[download_and_upload_video_by_creatomate_render_id] Failed to delete temp file: {e}")
|
||||||
|
|
||||||
# 임시 디렉토리 삭제 시도
|
# 임시 디렉토리 삭제 시도
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ O2O-CASTAD Backend는 FastAPI 기반의 비동기 백엔드 서비스로, AI 기
|
||||||
|
|
||||||
| 영역 | 개선 전 | 개선 후 | 개선율 |
|
| 영역 | 개선 전 | 개선 후 | 개선율 |
|
||||||
|------|---------|---------|--------|
|
|------|---------|---------|--------|
|
||||||
| DB 쿼리 실행 | 순차 (200ms) | 병렬 (55ms) | **72% 감소** |
|
|
||||||
| 템플릿 API 호출 | 매번 호출 (1-2s) | 캐시 HIT (0ms) | **100% 감소** |
|
| 템플릿 API 호출 | 매번 호출 (1-2s) | 캐시 HIT (0ms) | **100% 감소** |
|
||||||
| HTTP 클라이언트 | 매번 생성 (50ms) | 풀 재사용 (0ms) | **100% 감소** |
|
| HTTP 클라이언트 | 매번 생성 (50ms) | 풀 재사용 (0ms) | **100% 감소** |
|
||||||
| 세션 타임아웃 에러 | 빈번 | 해결 | **안정성 확보** |
|
| 세션 타임아웃 에러 | 빈번 | 해결 | **안정성 확보** |
|
||||||
|
|
@ -41,7 +40,7 @@ O2O-CASTAD Backend는 FastAPI 기반의 비동기 백엔드 서비스로, AI 기
|
||||||
1. **이중 커넥션 풀 아키텍처**: 요청/백그라운드 분리
|
1. **이중 커넥션 풀 아키텍처**: 요청/백그라운드 분리
|
||||||
2. **명시적 세션 라이프사이클**: 외부 API 호출 전 세션 해제
|
2. **명시적 세션 라이프사이클**: 외부 API 호출 전 세션 해제
|
||||||
3. **모듈 레벨 싱글톤**: HTTP 클라이언트 및 템플릿 캐시
|
3. **모듈 레벨 싱글톤**: HTTP 클라이언트 및 템플릿 캐시
|
||||||
4. **asyncio.gather() 기반 병렬 쿼리**: 다중 테이블 동시 조회
|
4. **순차 쿼리 실행**: AsyncSession 제약으로 단일 세션 내 병렬 쿼리 불가
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -78,7 +77,7 @@ engine = create_async_engine(
|
||||||
pool_size=20, # 기본 풀 크기
|
pool_size=20, # 기본 풀 크기
|
||||||
max_overflow=20, # 추가 연결 허용
|
max_overflow=20, # 추가 연결 허용
|
||||||
pool_timeout=30, # 연결 대기 최대 시간
|
pool_timeout=30, # 연결 대기 최대 시간
|
||||||
pool_recycle=3600, # 1시간마다 연결 재생성
|
pool_recycle=280, # MySQL wait_timeout 보다 짧게 설정
|
||||||
pool_pre_ping=True, # 연결 유효성 검사 (핵심!)
|
pool_pre_ping=True, # 연결 유효성 검사 (핵심!)
|
||||||
pool_reset_on_return="rollback", # 반환 시 롤백
|
pool_reset_on_return="rollback", # 반환 시 롤백
|
||||||
)
|
)
|
||||||
|
|
@ -89,8 +88,9 @@ background_engine = create_async_engine(
|
||||||
pool_size=10, # 더 작은 풀
|
pool_size=10, # 더 작은 풀
|
||||||
max_overflow=10,
|
max_overflow=10,
|
||||||
pool_timeout=60, # 백그라운드는 대기 여유
|
pool_timeout=60, # 백그라운드는 대기 여유
|
||||||
pool_recycle=3600,
|
pool_recycle=280, # MySQL wait_timeout 보다 짧게 설정
|
||||||
pool_pre_ping=True,
|
pool_pre_ping=True,
|
||||||
|
pool_reset_on_return="rollback", # 반환 시 롤백
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -120,8 +120,10 @@ async def get_item(
|
||||||
async def generate_video(task_id: str):
|
async def generate_video(task_id: str):
|
||||||
# 1단계: 명시적 세션 열기 → DB 작업 → 세션 닫기
|
# 1단계: 명시적 세션 열기 → DB 작업 → 세션 닫기
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
# 병렬 쿼리 실행
|
# 순차 쿼리 실행 (AsyncSession은 병렬 쿼리 미지원)
|
||||||
results = await asyncio.gather(...)
|
project = await session.execute(select(Project).where(...))
|
||||||
|
lyric = await session.execute(select(Lyric).where(...))
|
||||||
|
song = await session.execute(select(Song).where(...))
|
||||||
# 초기 데이터 저장
|
# 초기 데이터 저장
|
||||||
await session.commit()
|
await session.commit()
|
||||||
# 세션 닫힘 (async with 블록 종료)
|
# 세션 닫힘 (async with 블록 종료)
|
||||||
|
|
@ -193,38 +195,47 @@ async def generate_video():
|
||||||
|
|
||||||
## 3. 비동기 처리 패턴
|
## 3. 비동기 처리 패턴
|
||||||
|
|
||||||
### 3.1 asyncio.gather() 병렬 쿼리
|
### 3.1 순차 쿼리 실행 (AsyncSession 제약)
|
||||||
|
|
||||||
**위치**: `app/video/api/routers/v1/video.py`
|
**위치**: `app/video/api/routers/v1/video.py`
|
||||||
|
|
||||||
|
> **중요**: SQLAlchemy AsyncSession은 단일 세션에서 동시에 여러 쿼리를 실행하는 것을 지원하지 않습니다.
|
||||||
|
> `asyncio.gather()`로 병렬 쿼리를 실행하면 세션 상태 충돌이 발생합니다.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# 4개의 독립적인 쿼리를 병렬로 실행
|
# 순차 쿼리 실행: Project, Lyric, Song, Image
|
||||||
project_result, lyric_result, song_result, image_result = (
|
# Note: AsyncSession은 동일 세션에서 병렬 쿼리를 지원하지 않음
|
||||||
await asyncio.gather(
|
|
||||||
session.execute(project_query),
|
# Project 조회
|
||||||
session.execute(lyric_query),
|
project_result = await session.execute(
|
||||||
session.execute(song_query),
|
select(Project).where(Project.task_id == task_id)
|
||||||
session.execute(image_query),
|
.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())
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
**성능 비교:**
|
**AsyncSession 병렬 쿼리 제약 사항:**
|
||||||
```
|
|
||||||
[순차 실행]
|
|
||||||
Query 1 ──────▶ 50ms
|
|
||||||
Query 2 ──────▶ 50ms
|
|
||||||
Query 3 ──────▶ 50ms
|
|
||||||
Query 4 ──────▶ 50ms
|
|
||||||
총 소요시간: 200ms
|
|
||||||
|
|
||||||
[병렬 실행]
|
- 단일 세션 내에서 `asyncio.gather()`로 여러 쿼리 동시 실행 불가
|
||||||
Query 1 ──────▶ 50ms
|
- 세션 상태 충돌 및 예기치 않은 동작 발생 가능
|
||||||
Query 2 ──────▶ 50ms
|
- 병렬 쿼리가 필요한 경우 별도의 세션을 각각 생성해야 함
|
||||||
Query 3 ──────▶ 50ms
|
|
||||||
Query 4 ──────▶ 50ms
|
|
||||||
총 소요시간: ~55ms (가장 느린 쿼리 + 오버헤드)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 FastAPI BackgroundTasks 활용
|
### 3.2 FastAPI BackgroundTasks 활용
|
||||||
|
|
||||||
|
|
@ -553,7 +564,6 @@ query = (
|
||||||
|
|
||||||
| 요소 | 구현 | 효과 |
|
| 요소 | 구현 | 효과 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| asyncio.gather() | 병렬 쿼리 | 72% 응답 시간 단축 |
|
|
||||||
| 템플릿 캐싱 | TTL 기반 메모리 캐시 | API 호출 100% 감소 |
|
| 템플릿 캐싱 | TTL 기반 메모리 캐시 | API 호출 100% 감소 |
|
||||||
| HTTP 클라이언트 풀 | 싱글톤 패턴 | 커넥션 재사용 |
|
| HTTP 클라이언트 풀 | 싱글톤 패턴 | 커넥션 재사용 |
|
||||||
| N+1 해결 | IN 절 배치 조회 | 쿼리 수 N→2 감소 |
|
| N+1 해결 | IN 절 배치 조회 | 쿼리 수 N→2 감소 |
|
||||||
|
|
@ -721,7 +731,7 @@ async def generate_video_with_lock(task_id: str):
|
||||||
5. 영상 생성
|
5. 영상 생성
|
||||||
Client ─▶ GET /video/generate/{task_id}
|
Client ─▶ GET /video/generate/{task_id}
|
||||||
│
|
│
|
||||||
├─ asyncio.gather() ─▶ DB(Project, Lyric, Song, Image)
|
├─ 순차 쿼리 ─▶ DB(Project, Lyric, Song, Image)
|
||||||
│
|
│
|
||||||
├─ Creatomate API ─▶ render_id
|
├─ Creatomate API ─▶ render_id
|
||||||
│
|
│
|
||||||
|
|
@ -750,7 +760,7 @@ async def generate_video_with_lock(task_id: str):
|
||||||
O2O-CASTAD Backend는 **프로덕션 준비 수준의 비동기 아키텍처**를 갖추고 있습니다:
|
O2O-CASTAD Backend는 **프로덕션 준비 수준의 비동기 아키텍처**를 갖추고 있습니다:
|
||||||
|
|
||||||
1. **안정성**: 이중 커넥션 풀, pool_pre_ping, 명시적 세션 관리로 런타임 에러 최소화
|
1. **안정성**: 이중 커넥션 풀, pool_pre_ping, 명시적 세션 관리로 런타임 에러 최소화
|
||||||
2. **성능**: 병렬 쿼리, 캐싱, HTTP 클라이언트 풀링으로 응답 시간 최적화
|
2. **성능**: 캐싱, HTTP 클라이언트 풀링으로 응답 시간 최적화
|
||||||
3. **확장성**: 백그라운드 태스크 분리, 폴링 패턴으로 부하 분산
|
3. **확장성**: 백그라운드 태스크 분리, 폴링 패턴으로 부하 분산
|
||||||
4. **유지보수성**: 일관된 패턴, 구조화된 로깅, 타입 힌트
|
4. **유지보수성**: 일관된 패턴, 구조화된 로깅, 타입 힌트
|
||||||
|
|
||||||
|
|
@ -761,7 +771,6 @@ O2O-CASTAD Backend는 **프로덕션 준비 수준의 비동기 아키텍처**
|
||||||
│ BEFORE → AFTER │
|
│ BEFORE → AFTER │
|
||||||
├─────────────────────────────────────────────────────────────┤
|
├─────────────────────────────────────────────────────────────┤
|
||||||
│ Session Timeout Errors │ Frequent → Resolved │
|
│ Session Timeout Errors │ Frequent → Resolved │
|
||||||
│ DB Query Time │ 200ms → 55ms (72%↓) │
|
|
||||||
│ Template API Calls │ Every req → Cached (100%↓) │
|
│ Template API Calls │ Every req → Cached (100%↓) │
|
||||||
│ HTTP Client Overhead │ 50ms/req → 0ms (100%↓) │
|
│ HTTP Client Overhead │ 50ms/req → 0ms (100%↓) │
|
||||||
│ N+1 Query Problem │ N queries → 2 queries │
|
│ N+1 Query Problem │ N queries → 2 queries │
|
||||||
|
|
|
||||||
|
|
@ -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({
|
result = await lyric_chain.ainvoke({
|
||||||
|
|
@ -234,7 +234,7 @@ parser = PydanticOutputParser(pydantic_object=LyricOutput)
|
||||||
# 자동 수정 파서 (파싱 실패 시 LLM으로 재시도)
|
# 자동 수정 파서 (파싱 실패 시 LLM으로 재시도)
|
||||||
fixing_parser = OutputFixingParser.from_llm(
|
fixing_parser = OutputFixingParser.from_llm(
|
||||||
parser=parser,
|
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({
|
result = await chain.ainvoke({
|
||||||
"examples": examples_text,
|
"examples": examples_text,
|
||||||
|
|
@ -1128,7 +1128,7 @@ async def enrich_lyrics_with_region_info(
|
||||||
from langchain_openai import ChatOpenAI
|
from langchain_openai import ChatOpenAI
|
||||||
|
|
||||||
# Vision 모델로 이미지 분석
|
# Vision 모델로 이미지 분석
|
||||||
vision_model = ChatOpenAI(model="gpt-4o")
|
vision_model = ChatOpenAI(model="gpt-5-mini")
|
||||||
|
|
||||||
# 이미지 분석 문서 구조
|
# 이미지 분석 문서 구조
|
||||||
class ImageAnalysis(BaseModel):
|
class ImageAnalysis(BaseModel):
|
||||||
|
|
@ -1151,7 +1151,7 @@ image_store = Chroma(
|
||||||
async def analyze_and_store_image(image_url: str, task_id: str):
|
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([
|
analysis_response = await vision_model.ainvoke([
|
||||||
{
|
{
|
||||||
"type": "text",
|
"type": "text",
|
||||||
|
|
|
||||||
|
|
@ -801,7 +801,7 @@ from app.infrastructure.external.chatgpt.prompts import LYRICS_PROMPT_TEMPLATE
|
||||||
class ChatGPTClient(ILLMClient):
|
class ChatGPTClient(ILLMClient):
|
||||||
"""ChatGPT 클라이언트 구현"""
|
"""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._client = AsyncOpenAI(api_key=api_key)
|
||||||
self._model = model
|
self._model = model
|
||||||
|
|
||||||
|
|
@ -956,7 +956,7 @@ def get_chatgpt_client(
|
||||||
) -> ChatGPTClient:
|
) -> ChatGPTClient:
|
||||||
return ChatGPTClient(
|
return ChatGPTClient(
|
||||||
api_key=settings.CHATGPT_API_KEY,
|
api_key=settings.CHATGPT_API_KEY,
|
||||||
model="gpt-4o"
|
model="gpt-5-mini"
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_suno_client(
|
def get_suno_client(
|
||||||
|
|
|
||||||
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
|
@ -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())
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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))
|
||||||
|
|
@ -9,6 +9,7 @@ dependencies = [
|
||||||
"aiohttp>=3.13.2",
|
"aiohttp>=3.13.2",
|
||||||
"aiomysql>=0.3.2",
|
"aiomysql>=0.3.2",
|
||||||
"asyncmy>=0.2.10",
|
"asyncmy>=0.2.10",
|
||||||
|
"beautifulsoup4>=4.14.3",
|
||||||
"fastapi-cli>=0.0.16",
|
"fastapi-cli>=0.0.16",
|
||||||
"fastapi[standard]>=0.125.0",
|
"fastapi[standard]>=0.125.0",
|
||||||
"openai>=2.13.0",
|
"openai>=2.13.0",
|
||||||
|
|
|
||||||
24
uv.lock
|
|
@ -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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2025.11.12"
|
version = "2025.11.12"
|
||||||
|
|
@ -751,6 +764,7 @@ dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
{ name = "aiomysql" },
|
{ name = "aiomysql" },
|
||||||
{ name = "asyncmy" },
|
{ name = "asyncmy" },
|
||||||
|
{ name = "beautifulsoup4" },
|
||||||
{ name = "fastapi", extra = ["standard"] },
|
{ name = "fastapi", extra = ["standard"] },
|
||||||
{ name = "fastapi-cli" },
|
{ name = "fastapi-cli" },
|
||||||
{ name = "openai" },
|
{ name = "openai" },
|
||||||
|
|
@ -775,6 +789,7 @@ requires-dist = [
|
||||||
{ name = "aiohttp", specifier = ">=3.13.2" },
|
{ name = "aiohttp", specifier = ">=3.13.2" },
|
||||||
{ name = "aiomysql", specifier = ">=0.3.2" },
|
{ name = "aiomysql", specifier = ">=0.3.2" },
|
||||||
{ name = "asyncmy", specifier = ">=0.2.10" },
|
{ name = "asyncmy", specifier = ">=0.2.10" },
|
||||||
|
{ name = "beautifulsoup4", specifier = ">=4.14.3" },
|
||||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.125.0" },
|
{ name = "fastapi", extras = ["standard"], specifier = ">=0.125.0" },
|
||||||
{ name = "fastapi-cli", specifier = ">=0.0.16" },
|
{ name = "fastapi-cli", specifier = ">=0.0.16" },
|
||||||
{ name = "openai", specifier = ">=2.13.0" },
|
{ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "sqladmin"
|
name = "sqladmin"
|
||||||
version = "0.22.0"
|
version = "0.22.0"
|
||||||
|
|
|
||||||