742 lines
30 KiB
Python
742 lines
30 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
카카오 API 기반 음식점 수집 서비스
|
|
review-api/restaurant/app/main.py
|
|
"""
|
|
|
|
import os
|
|
import json
|
|
import asyncio
|
|
import aiohttp
|
|
import logging
|
|
import sys
|
|
from datetime import datetime
|
|
from typing import Optional, List, Dict, Any
|
|
from fastapi import FastAPI, HTTPException, BackgroundTasks, Query
|
|
from fastapi.responses import HTMLResponse, FileResponse
|
|
from pydantic import BaseModel, Field
|
|
import uvicorn
|
|
|
|
# =============================================================================
|
|
# .env 파일 로딩 (다른 import보다 먼저)
|
|
# =============================================================================
|
|
from dotenv import load_dotenv
|
|
|
|
# .env 파일에서 환경변수 로드
|
|
load_dotenv()
|
|
|
|
# =============================================================================
|
|
# 로깅 설정
|
|
# =============================================================================
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
|
handlers=[logging.StreamHandler(sys.stdout)]
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# =============================================================================
|
|
# 환경 변수 설정
|
|
# =============================================================================
|
|
class Config:
|
|
"""환경 변수 기반 설정 클래스"""
|
|
|
|
# 애플리케이션 메타데이터
|
|
APP_TITLE = os.getenv("APP_TITLE", "카카오 API 기반 음식점 수집 서비스")
|
|
APP_VERSION = os.getenv("APP_VERSION", "1.0.0")
|
|
APP_DESCRIPTION = os.getenv("APP_DESCRIPTION", "카카오 로컬 API를 활용한 음식점 정보 수집 시스템")
|
|
|
|
# 서버 설정
|
|
HOST = os.getenv("HOST", "0.0.0.0")
|
|
PORT = int(os.getenv("PORT", "8000"))
|
|
LOG_LEVEL = os.getenv("LOG_LEVEL", "info")
|
|
|
|
# 카카오 API 설정
|
|
KAKAO_API_KEY = os.getenv("KAKAO_API_KEY", "5cdc24407edbf8544f3954cfaa4650c6")
|
|
KAKAO_API_URL = "https://dapi.kakao.com/v2/local/search/keyword.json"
|
|
|
|
# 검색 설정
|
|
DEFAULT_QUERY = os.getenv("DEFAULT_QUERY", "음식점")
|
|
DEFAULT_REGION = os.getenv("DEFAULT_REGION", "서울")
|
|
DEFAULT_SIZE = int(os.getenv("DEFAULT_SIZE", "15"))
|
|
MAX_SIZE = int(os.getenv("MAX_SIZE", "15")) # 카카오 API 최대값
|
|
MAX_PAGES = int(os.getenv("MAX_PAGES", "45")) # 카카오 API 최대 페이지 (45페이지 * 15 = 675개)
|
|
|
|
# 파일 설정
|
|
OUTPUT_FILE = os.getenv("OUTPUT_FILE", "restaurant.json")
|
|
DATA_DIR = os.getenv("DATA_DIR", "./data")
|
|
|
|
# 요청 제한 설정
|
|
REQUEST_DELAY = float(os.getenv("REQUEST_DELAY", "0.1")) # API 요청 간 지연시간(초)
|
|
REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", "30"))
|
|
|
|
# 헬스체크 설정
|
|
HEALTH_CHECK_TIMEOUT = int(os.getenv("HEALTH_CHECK_TIMEOUT", "10"))
|
|
|
|
config = Config()
|
|
|
|
# 데이터 디렉토리 생성
|
|
os.makedirs(config.DATA_DIR, exist_ok=True)
|
|
|
|
# FastAPI 앱 초기화
|
|
app = FastAPI(
|
|
title=config.APP_TITLE,
|
|
description=f"""
|
|
{config.APP_DESCRIPTION}
|
|
|
|
**주요 기능:**
|
|
- 카카오 로컬 API를 활용한 음식점 정보 수집
|
|
- 지역별, 키워드별 검색 지원
|
|
- JSON 파일 자동 저장 기능
|
|
- RESTful API 제공
|
|
- Swagger UI 문서 제공
|
|
|
|
**API 키:** {config.KAKAO_API_KEY[:10]}...
|
|
**버전:** {config.APP_VERSION}
|
|
""",
|
|
version=config.APP_VERSION,
|
|
contact={
|
|
"name": "관리자",
|
|
"email": "admin@example.com"
|
|
}
|
|
)
|
|
|
|
# =============================================================================
|
|
# Pydantic 모델 정의
|
|
# =============================================================================
|
|
|
|
class RestaurantSearchRequest(BaseModel):
|
|
"""음식점 검색 요청 모델"""
|
|
query: str = Field(
|
|
default=config.DEFAULT_QUERY,
|
|
description="검색 키워드 (예: 치킨, 피자, 한식)",
|
|
example="치킨"
|
|
)
|
|
region: str = Field(
|
|
default=config.DEFAULT_REGION,
|
|
description="검색 지역 (예: 서울, 부산, 대구)",
|
|
example="서울"
|
|
)
|
|
size: int = Field(
|
|
default=config.DEFAULT_SIZE,
|
|
description=f"페이지당 결과 수 (1-{config.MAX_SIZE})",
|
|
example=15,
|
|
ge=1,
|
|
le=config.MAX_SIZE
|
|
)
|
|
pages: int = Field(
|
|
default=5,
|
|
description=f"검색할 페이지 수 (1-{config.MAX_PAGES})",
|
|
example=5,
|
|
ge=1,
|
|
le=config.MAX_PAGES
|
|
)
|
|
save_to_file: bool = Field(
|
|
default=True,
|
|
description="결과를 JSON 파일로 저장할지 여부",
|
|
example=True
|
|
)
|
|
|
|
class RestaurantInfo(BaseModel):
|
|
"""음식점 정보 모델"""
|
|
id: str = Field(description="카카오 장소 ID")
|
|
place_name: str = Field(description="장소명")
|
|
category_name: str = Field(description="카테고리명")
|
|
category_group_code: str = Field(description="카테고리 그룹 코드")
|
|
category_group_name: str = Field(description="카테고리 그룹명")
|
|
phone: str = Field(description="전화번호")
|
|
address_name: str = Field(description="전체 지번 주소")
|
|
road_address_name: str = Field(description="전체 도로명 주소")
|
|
place_url: str = Field(description="장소 상세페이지 URL")
|
|
distance: str = Field(description="중심좌표까지의 거리 (meter)")
|
|
x: str = Field(description="X 좌표값, 경위도인 경우 longitude")
|
|
y: str = Field(description="Y 좌표값, 경위도인 경우 latitude")
|
|
|
|
class CollectionMetadata(BaseModel):
|
|
"""수집 메타데이터"""
|
|
collection_date: str = Field(description="수집 날짜시간")
|
|
query: str = Field(description="검색 키워드")
|
|
region: str = Field(description="검색 지역")
|
|
total_count: int = Field(description="총 수집된 음식점 수")
|
|
pages_collected: int = Field(description="수집된 페이지 수")
|
|
api_key_used: str = Field(description="사용된 API 키 (마스킹)")
|
|
execution_time: float = Field(description="실행 시간(초)")
|
|
|
|
class RestaurantSearchResponse(BaseModel):
|
|
"""음식점 검색 응답 모델"""
|
|
success: bool = Field(description="검색 성공 여부")
|
|
message: str = Field(description="응답 메시지")
|
|
metadata: CollectionMetadata
|
|
restaurants: List[RestaurantInfo]
|
|
file_saved: bool = Field(description="파일 저장 여부")
|
|
file_path: Optional[str] = Field(description="저장된 파일 경로")
|
|
|
|
class ErrorResponse(BaseModel):
|
|
"""에러 응답 모델"""
|
|
success: bool = False
|
|
error: str
|
|
message: str
|
|
timestamp: str
|
|
|
|
# =============================================================================
|
|
# 카카오 API 클라이언트
|
|
# =============================================================================
|
|
|
|
class KakaoRestaurantCollector:
|
|
"""카카오 API를 활용한 음식점 수집기"""
|
|
|
|
def __init__(self):
|
|
self.api_key = config.KAKAO_API_KEY
|
|
self.api_url = config.KAKAO_API_URL
|
|
self.session = None
|
|
logger.info("KakaoRestaurantCollector 초기화 완료")
|
|
|
|
async def __aenter__(self):
|
|
"""비동기 컨텍스트 매니저 진입"""
|
|
self.session = aiohttp.ClientSession(
|
|
timeout=aiohttp.ClientTimeout(total=config.REQUEST_TIMEOUT),
|
|
headers={
|
|
'Authorization': f'KakaoAK {self.api_key}',
|
|
'Content-Type': 'application/json'
|
|
}
|
|
)
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
"""비동기 컨텍스트 매니저 종료"""
|
|
if self.session:
|
|
await self.session.close()
|
|
|
|
async def search_restaurants(self, query: str, region: str, size: int = 15, pages: int = 5) -> Dict[str, Any]:
|
|
"""음식점 검색 실행 (개선된 버전)"""
|
|
logger.info(f"음식점 검색 시작: query='{query}', region='{region}', size={size}, pages={pages}")
|
|
|
|
start_time = datetime.now()
|
|
all_restaurants = []
|
|
collected_pages = 0
|
|
empty_page_count = 0 # 연속 빈 페이지 카운터 추가
|
|
|
|
try:
|
|
for page in range(1, pages + 1):
|
|
logger.info(f"페이지 {page}/{pages} 검색 중...")
|
|
|
|
# API 요청 파라미터 - 더 구체적인 검색
|
|
params = {
|
|
'query': f"{query} {region}",
|
|
'category_group_code': 'FD6', # 음식점 카테고리
|
|
'page': page,
|
|
'size': size,
|
|
'sort': 'accuracy'
|
|
}
|
|
|
|
try:
|
|
async with self.session.get(self.api_url, params=params) as response:
|
|
if response.status == 200:
|
|
data = await response.json()
|
|
documents = data.get('documents', [])
|
|
|
|
if not documents:
|
|
empty_page_count += 1
|
|
logger.warning(f"페이지 {page}에서 결과 없음 (연속 빈 페이지: {empty_page_count})")
|
|
|
|
# 연속 3페이지가 비어있으면 종료 (개선)
|
|
if empty_page_count >= 3:
|
|
logger.info(f"연속 {empty_page_count}페이지 빈 결과로 검색 종료")
|
|
break
|
|
|
|
# 빈 페이지여도 계속 진행
|
|
await asyncio.sleep(config.REQUEST_DELAY)
|
|
continue
|
|
|
|
# 결과가 있으면 빈 페이지 카운터 리셋
|
|
empty_page_count = 0
|
|
|
|
# 음식점 정보 추출 및 저장
|
|
page_restaurants = []
|
|
for doc in documents:
|
|
restaurant = self._extract_restaurant_info(doc)
|
|
if restaurant:
|
|
page_restaurants.append(restaurant)
|
|
|
|
all_restaurants.extend(page_restaurants)
|
|
collected_pages += 1
|
|
|
|
logger.info(f"페이지 {page} 완료: {len(page_restaurants)}개 음식점 수집 (총: {len(all_restaurants)}개)")
|
|
|
|
# API 요청 제한을 위한 지연
|
|
if page < pages:
|
|
await asyncio.sleep(config.REQUEST_DELAY)
|
|
|
|
elif response.status == 400:
|
|
error_data = await response.json()
|
|
logger.warning(f"API 요청 오류 (페이지 {page}): {error_data}")
|
|
|
|
# 400 오류여도 계속 진행 (다른 페이지는 성공할 수 있음)
|
|
empty_page_count += 1
|
|
if empty_page_count >= 5:
|
|
break
|
|
continue
|
|
|
|
elif response.status == 401:
|
|
logger.error("API 키 인증 실패")
|
|
raise HTTPException(status_code=401, detail="카카오 API 키 인증 실패")
|
|
|
|
elif response.status == 429:
|
|
logger.warning("API 요청 한도 초과 - 2초 대기 후 재시도")
|
|
await asyncio.sleep(2)
|
|
continue
|
|
|
|
else:
|
|
logger.error(f"API 요청 실패: HTTP {response.status}")
|
|
empty_page_count += 1
|
|
if empty_page_count >= 5:
|
|
break
|
|
continue
|
|
|
|
except asyncio.TimeoutError:
|
|
logger.error(f"페이지 {page} 요청 타임아웃")
|
|
empty_page_count += 1
|
|
if empty_page_count >= 5:
|
|
break
|
|
continue
|
|
except Exception as e:
|
|
logger.error(f"페이지 {page} 요청 중 오류: {e}")
|
|
empty_page_count += 1
|
|
if empty_page_count >= 5:
|
|
break
|
|
continue
|
|
|
|
execution_time = (datetime.now() - start_time).total_seconds()
|
|
|
|
# 중복 제거
|
|
|
|
logger.info(f"중복 제거 시작: {len(all_restaurants)}개")
|
|
|
|
# 중복 제거 통계
|
|
stats = {
|
|
'total': len(all_restaurants),
|
|
'by_place_url': 0,
|
|
'by_place_id': 0,
|
|
'by_coordinates': 0,
|
|
'duplicates': 0
|
|
}
|
|
|
|
unique_restaurants = {}
|
|
|
|
for restaurant in all_restaurants:
|
|
place_url = restaurant.get('place_url', '').strip()
|
|
place_id = restaurant.get('id', '').strip()
|
|
place_name = restaurant.get('place_name', '').strip()
|
|
x = restaurant.get('x', '').strip()
|
|
y = restaurant.get('y', '').strip()
|
|
|
|
# 고유 키 생성 (다중 전략)
|
|
unique_key = None
|
|
|
|
if place_url:
|
|
unique_key = f"url:{place_url}"
|
|
stats['by_place_url'] += 1
|
|
elif place_id:
|
|
unique_key = f"id:{place_id}"
|
|
stats['by_place_id'] += 1
|
|
elif place_name and x and y:
|
|
unique_key = f"coord:{place_name}:{x}:{y}"
|
|
stats['by_coordinates'] += 1
|
|
else:
|
|
# 마지막 수단: 이름만으로
|
|
unique_key = f"name:{place_name}"
|
|
|
|
if unique_key:
|
|
if unique_key not in unique_restaurants:
|
|
unique_restaurants[unique_key] = restaurant
|
|
else:
|
|
stats['duplicates'] += 1
|
|
|
|
final_restaurants = list(unique_restaurants.values())
|
|
|
|
logger.info(f"중복 제거 완료:")
|
|
logger.info(f" 총 입력: {stats['total']}개")
|
|
logger.info(f" URL 기준: {stats['by_place_url']}개")
|
|
logger.info(f" ID 기준: {stats['by_place_id']}개")
|
|
logger.info(f" 좌표 기준: {stats['by_coordinates']}개")
|
|
logger.info(f" 중복 제거: {stats['duplicates']}개")
|
|
logger.info(f" 최종 결과: {len(final_restaurants)}개")
|
|
logger.info(f" 중복률: {(stats['duplicates']/stats['total']*100):.1f}%")
|
|
|
|
logger.info(f"검색 완료: 총 {len(final_restaurants)}개 음식점 수집 (중복 제거 후), 수집된 페이지: {collected_pages}")
|
|
|
|
return {
|
|
'restaurants': final_restaurants,
|
|
'metadata': {
|
|
'collection_date': start_time.isoformat(),
|
|
'query': query,
|
|
'region': region,
|
|
'total_count': len(final_restaurants),
|
|
'pages_collected': collected_pages,
|
|
'pages_requested': pages,
|
|
'api_key_used': f"{self.api_key[:10]}...",
|
|
'execution_time': execution_time
|
|
}
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"음식점 검색 중 오류: {e}")
|
|
raise
|
|
|
|
def _extract_restaurant_info(self, document: Dict) -> Dict[str, Any]:
|
|
"""카카오 API 응답에서 음식점 정보 추출"""
|
|
try:
|
|
return {
|
|
'id': document.get('id', ''),
|
|
'place_name': document.get('place_name', ''),
|
|
'category_name': document.get('category_name', ''),
|
|
'category_group_code': document.get('category_group_code', ''),
|
|
'category_group_name': document.get('category_group_name', ''),
|
|
'phone': document.get('phone', ''),
|
|
'address_name': document.get('address_name', ''),
|
|
'road_address_name': document.get('road_address_name', ''),
|
|
'place_url': document.get('place_url', ''),
|
|
'distance': document.get('distance', ''),
|
|
'x': document.get('x', ''),
|
|
'y': document.get('y', '')
|
|
}
|
|
except Exception as e:
|
|
logger.warning(f"음식점 정보 추출 실패: {e}")
|
|
return None
|
|
|
|
# =============================================================================
|
|
# 파일 관리 유틸리티
|
|
# =============================================================================
|
|
|
|
def save_restaurants_to_file(data: Dict[str, Any], filename: str = None) -> str:
|
|
"""음식점 데이터를 JSON 파일로 저장"""
|
|
if filename is None:
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
filename = f"restaurants_{timestamp}.json"
|
|
|
|
file_path = os.path.join(config.DATA_DIR, filename)
|
|
|
|
try:
|
|
with open(file_path, 'w', encoding='utf-8') as f:
|
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
|
|
logger.info(f"음식점 데이터 저장 완료: {file_path}")
|
|
return file_path
|
|
|
|
except Exception as e:
|
|
logger.error(f"파일 저장 실패: {e}")
|
|
raise
|
|
|
|
# =============================================================================
|
|
# API 엔드포인트
|
|
# =============================================================================
|
|
|
|
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
|
|
async def root():
|
|
"""메인 페이지"""
|
|
return f"""
|
|
<html>
|
|
<head>
|
|
<title>{config.APP_TITLE}</title>
|
|
<style>
|
|
body {{ font-family: Arial, sans-serif; margin: 40px; }}
|
|
.header {{ background: #FEE500; color: #3C1E1E; padding: 20px; border-radius: 5px; }}
|
|
.info {{ background: #f8f9fa; border: 1px solid #dee2e6; padding: 15px; margin: 15px 0; border-radius: 5px; }}
|
|
.link {{ display: inline-block; background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; margin: 10px 5px; }}
|
|
.config {{ background: #e8f4fd; padding: 15px; margin: 15px 0; border-radius: 5px; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1>🍽️ {config.APP_TITLE}</h1>
|
|
<p>카카오 로컬 API를 활용한 음식점 정보 수집 시스템</p>
|
|
<p>버전: {config.APP_VERSION}</p>
|
|
</div>
|
|
|
|
<div class="info">
|
|
<h2>📋 서비스 정보</h2>
|
|
<ul>
|
|
<li><strong>API 키:</strong> {config.KAKAO_API_KEY[:10]}... (카카오 로컬 API)</li>
|
|
<li><strong>기본 검색어:</strong> {config.DEFAULT_QUERY}</li>
|
|
<li><strong>기본 지역:</strong> {config.DEFAULT_REGION}</li>
|
|
<li><strong>최대 페이지:</strong> {config.MAX_PAGES}</li>
|
|
<li><strong>저장 경로:</strong> {config.DATA_DIR}</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="config">
|
|
<h2>⚙️ 환경 설정</h2>
|
|
<ul>
|
|
<li><strong>요청 지연:</strong> {config.REQUEST_DELAY}초</li>
|
|
<li><strong>요청 타임아웃:</strong> {config.REQUEST_TIMEOUT}초</li>
|
|
<li><strong>페이지당 결과:</strong> 최대 {config.MAX_SIZE}개</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<h2>📚 API 문서</h2>
|
|
<a href="/docs" class="link">Swagger UI 문서</a>
|
|
<a href="/redoc" class="link">ReDoc 문서</a>
|
|
<a href="/health" class="link">헬스 체크</a>
|
|
|
|
<h2>🛠️ 사용 방법</h2>
|
|
<p><strong>POST /collect</strong> - 음식점 정보 수집</p>
|
|
<pre>
|
|
{{
|
|
"query": "치킨",
|
|
"region": "서울",
|
|
"size": 15,
|
|
"pages": 5,
|
|
"save_to_file": true
|
|
}}
|
|
</pre>
|
|
|
|
<p><strong>GET /download/{filename}</strong> - 저장된 파일 다운로드</p>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
@app.post(
|
|
"/collect",
|
|
response_model=RestaurantSearchResponse,
|
|
summary="음식점 정보 수집",
|
|
description="""
|
|
카카오 로컬 API를 사용하여 지정된 조건의 음식점 정보를 수집합니다.
|
|
|
|
**주요 기능:**
|
|
- 키워드 및 지역 기반 음식점 검색
|
|
- 페이지네이션 지원 (최대 45페이지)
|
|
- JSON 파일 자동 저장
|
|
- 중복 음식점 제거
|
|
|
|
**응답 시간:** 페이지 수에 따라 5초-60초 소요
|
|
""",
|
|
responses={
|
|
200: {"description": "수집 성공", "model": RestaurantSearchResponse},
|
|
400: {"description": "잘못된 요청", "model": ErrorResponse},
|
|
401: {"description": "API 키 인증 실패", "model": ErrorResponse},
|
|
500: {"description": "서버 오류", "model": ErrorResponse}
|
|
}
|
|
)
|
|
async def collect_restaurants(request: RestaurantSearchRequest):
|
|
"""음식점 정보 수집 API"""
|
|
logger.info(f"음식점 수집 요청: {request}")
|
|
|
|
try:
|
|
async with KakaoRestaurantCollector() as collector:
|
|
# 음식점 검색 실행
|
|
result = await collector.search_restaurants(
|
|
query=request.query,
|
|
region=request.region,
|
|
size=request.size,
|
|
pages=request.pages
|
|
)
|
|
|
|
# 파일 저장
|
|
file_saved = False
|
|
file_path = None
|
|
|
|
if request.save_to_file:
|
|
try:
|
|
# 파일명 생성 (쿼리와 지역 포함)
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
safe_query = "".join(c for c in request.query if c.isalnum() or c in (' ', '_')).strip()
|
|
safe_region = "".join(c for c in request.region if c.isalnum() or c in (' ', '_')).strip()
|
|
filename = f"restaurants_{safe_query}_{safe_region}_{timestamp}.json"
|
|
|
|
file_path = save_restaurants_to_file(result, filename)
|
|
file_saved = True
|
|
|
|
# 기본 파일명으로도 저장 (최신 결과)
|
|
default_path = save_restaurants_to_file(result, config.OUTPUT_FILE)
|
|
logger.info(f"기본 파일로도 저장: {default_path}")
|
|
|
|
except Exception as save_error:
|
|
logger.error(f"파일 저장 실패: {save_error}")
|
|
file_saved = False
|
|
|
|
# 응답 데이터 구성
|
|
response_data = RestaurantSearchResponse(
|
|
success=True,
|
|
message=f"음식점 정보 수집이 완료되었습니다. (총 {len(result['restaurants'])}개)",
|
|
metadata=CollectionMetadata(**result['metadata']),
|
|
restaurants=[RestaurantInfo(**r) for r in result['restaurants']],
|
|
file_saved=file_saved,
|
|
file_path=file_path
|
|
)
|
|
|
|
logger.info(f"수집 완료: {len(result['restaurants'])}개 음식점")
|
|
return response_data
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"음식점 수집 실패: {str(e)}")
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail={
|
|
"success": False,
|
|
"error": "COLLECTION_FAILED",
|
|
"message": f"음식점 수집 중 오류가 발생했습니다: {str(e)}",
|
|
"timestamp": datetime.now().isoformat()
|
|
}
|
|
)
|
|
|
|
@app.get("/list-files", summary="저장된 파일 목록", description="저장된 음식점 데이터 파일 목록을 반환합니다.")
|
|
async def list_files():
|
|
"""저장된 파일 목록 조회"""
|
|
try:
|
|
files = []
|
|
if os.path.exists(config.DATA_DIR):
|
|
for filename in os.listdir(config.DATA_DIR):
|
|
if filename.endswith('.json'):
|
|
file_path = os.path.join(config.DATA_DIR, filename)
|
|
file_stat = os.stat(file_path)
|
|
|
|
files.append({
|
|
'filename': filename,
|
|
'size': file_stat.st_size,
|
|
'created': datetime.fromtimestamp(file_stat.st_ctime).isoformat(),
|
|
'modified': datetime.fromtimestamp(file_stat.st_mtime).isoformat(),
|
|
'download_url': f"/download/{filename}"
|
|
})
|
|
|
|
files.sort(key=lambda x: x['modified'], reverse=True)
|
|
|
|
return {
|
|
'success': True,
|
|
'total_files': len(files),
|
|
'files': files,
|
|
'data_directory': config.DATA_DIR
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"파일 목록 조회 실패: {e}")
|
|
raise HTTPException(status_code=500, detail=f"파일 목록 조회 실패: {str(e)}")
|
|
|
|
@app.get("/download/{filename}", summary="파일 다운로드", description="저장된 음식점 데이터 파일을 다운로드합니다.")
|
|
async def download_file(filename: str):
|
|
"""파일 다운로드"""
|
|
file_path = os.path.join(config.DATA_DIR, filename)
|
|
|
|
if not os.path.exists(file_path):
|
|
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다.")
|
|
|
|
if not filename.endswith('.json'):
|
|
raise HTTPException(status_code=400, detail="JSON 파일만 다운로드 가능합니다.")
|
|
|
|
return FileResponse(
|
|
path=file_path,
|
|
filename=filename,
|
|
media_type='application/json'
|
|
)
|
|
|
|
@app.get("/health", summary="헬스 체크", description="API 서버 상태를 확인합니다.")
|
|
async def health_check():
|
|
"""헬스 체크"""
|
|
return {
|
|
"status": "healthy",
|
|
"timestamp": datetime.now().isoformat(),
|
|
"version": config.APP_VERSION,
|
|
"api_key_configured": bool(config.KAKAO_API_KEY),
|
|
"data_directory_exists": os.path.exists(config.DATA_DIR),
|
|
"message": f"{config.APP_TITLE}이 정상 작동 중입니다."
|
|
}
|
|
|
|
@app.get("/config", summary="환경 설정 확인", description="현재 적용된 환경 변수 설정을 확인합니다.")
|
|
async def get_config():
|
|
"""환경 설정 확인"""
|
|
return {
|
|
"app_info": {
|
|
"title": config.APP_TITLE,
|
|
"version": config.APP_VERSION,
|
|
"description": config.APP_DESCRIPTION
|
|
},
|
|
"server_config": {
|
|
"host": config.HOST,
|
|
"port": config.PORT,
|
|
"log_level": config.LOG_LEVEL
|
|
},
|
|
"api_config": {
|
|
"kakao_api_url": config.KAKAO_API_URL,
|
|
"api_key_configured": bool(config.KAKAO_API_KEY),
|
|
"api_key_preview": f"{config.KAKAO_API_KEY[:10]}..." if config.KAKAO_API_KEY else None
|
|
},
|
|
"search_defaults": {
|
|
"default_query": config.DEFAULT_QUERY,
|
|
"default_region": config.DEFAULT_REGION,
|
|
"default_size": config.DEFAULT_SIZE,
|
|
"max_size": config.MAX_SIZE,
|
|
"max_pages": config.MAX_PAGES
|
|
},
|
|
"file_config": {
|
|
"output_file": config.OUTPUT_FILE,
|
|
"data_dir": config.DATA_DIR,
|
|
"data_dir_exists": os.path.exists(config.DATA_DIR)
|
|
},
|
|
"request_config": {
|
|
"request_delay": config.REQUEST_DELAY,
|
|
"request_timeout": config.REQUEST_TIMEOUT,
|
|
"health_check_timeout": config.HEALTH_CHECK_TIMEOUT
|
|
},
|
|
"timestamp": datetime.now().isoformat()
|
|
}
|
|
|
|
# 백그라운드 작업을 위한 예제 (향후 확장용)
|
|
@app.post("/collect-background", summary="백그라운드 수집", description="음식점 정보를 백그라운드에서 수집합니다.")
|
|
async def collect_restaurants_background(background_tasks: BackgroundTasks, request: RestaurantSearchRequest):
|
|
"""백그라운드 음식점 수집"""
|
|
|
|
async def background_collect():
|
|
try:
|
|
logger.info("백그라운드 수집 시작")
|
|
async with KakaoRestaurantCollector() as collector:
|
|
result = await collector.search_restaurants(
|
|
query=request.query,
|
|
region=request.region,
|
|
size=request.size,
|
|
pages=request.pages
|
|
)
|
|
|
|
if request.save_to_file:
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
filename = f"restaurants_bg_{timestamp}.json"
|
|
save_restaurants_to_file(result, filename)
|
|
|
|
logger.info(f"백그라운드 수집 완료: {len(result['restaurants'])}개")
|
|
|
|
except Exception as e:
|
|
logger.error(f"백그라운드 수집 실패: {e}")
|
|
|
|
background_tasks.add_task(background_collect)
|
|
|
|
return {
|
|
"success": True,
|
|
"message": "백그라운드 수집이 시작되었습니다.",
|
|
"timestamp": datetime.now().isoformat()
|
|
}
|
|
|
|
if __name__ == "__main__":
|
|
print("🍽️ " + "="*60)
|
|
print(f" {config.APP_TITLE} 서버 시작")
|
|
print("="*64)
|
|
print(f"📊 설정 정보:")
|
|
print(f" - API 키: {config.KAKAO_API_KEY[:10]}...")
|
|
print(f" - 기본 검색어: {config.DEFAULT_QUERY}")
|
|
print(f" - 기본 지역: {config.DEFAULT_REGION}")
|
|
print(f" - 데이터 저장 경로: {config.DATA_DIR}")
|
|
print()
|
|
print(f"📚 문서:")
|
|
print(f" - Swagger UI: http://{config.HOST}:{config.PORT}/docs")
|
|
print(f" - ReDoc: http://{config.HOST}:{config.PORT}/redoc")
|
|
print(f" - 메인 페이지: http://{config.HOST}:{config.PORT}/")
|
|
print()
|
|
|
|
uvicorn.run(
|
|
app,
|
|
host=config.HOST,
|
|
port=config.PORT,
|
|
log_level=config.LOG_LEVEL
|
|
)
|
|
|