2025-06-15 13:52:26 +00:00

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
)