diff --git a/CHANGELOG.json b/CHANGELOG.json index 68128de..99b791e 100644 --- a/CHANGELOG.json +++ b/CHANGELOG.json @@ -1,11 +1,36 @@ { "metadata": { - "lastUpdated": "2026-01-18T12:11:27Z", - "currentVersion": "0.1.3", + "lastUpdated": "2026-01-18T12:31:21Z", + "currentVersion": "1.0.1", "projectType": "python", - "totalReleases": 5 + "totalReleases": 6 }, "releases": [ + { + "version": "1.0.1", + "project_type": "python", + "date": "2026-01-18", + "pr_number": 15, + "raw_summary": "## Summary by CodeRabbit\n\n* **새로운 기능**\n * Geocoding API 엔드포인트 추가 - 주소를 좌표(위도/경도)로 변환\n * 카카오맵 및 Nominatim 제공자 지원\n * 다중 제공자 자동 폴백 기능\n * 장소 정보의 누락된 좌표 자동 채우기\n\n* **업데이트**\n * 버전 1.0.1 출시", + "parsed_changes": { + "새로운_기능": { + "title": "새로운 기능", + "items": [ + "Geocoding API 엔드포인트 추가 - 주소를 좌표(위도/경도)로 변환", + "카카오맵 및 Nominatim 제공자 지원", + "다중 제공자 자동 폴백 기능", + "장소 정보의 누락된 좌표 자동 채우기" + ] + }, + "업데이트": { + "title": "업데이트", + "items": [ + "버전 1.0.1 출시" + ] + } + }, + "parse_method": "markdown" + }, { "version": "0.1.3", "project_type": "python", diff --git a/CHANGELOG.md b/CHANGELOG.md index 63a2909..caf4140 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,22 @@ # Changelog -**현재 버전:** 0.1.3 -**마지막 업데이트:** 2026-01-18T12:11:27Z +**현재 버전:** 1.0.1 +**마지막 업데이트:** 2026-01-18T12:31:21Z + +--- + +## [1.0.1] - 2026-01-18 + +**PR:** #15 + +**새로운 기능** +- Geocoding API 엔드포인트 추가 - 주소를 좌표(위도/경도)로 변환 +- 카카오맵 및 Nominatim 제공자 지원 +- 다중 제공자 자동 폴백 기능 +- 장소 정보의 누락된 좌표 자동 채우기 + +**업데이트** +- 버전 1.0.1 출시 --- diff --git a/CLAUDE.md b/CLAUDE.md index 8443f2b..1ff9923 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,6 +96,53 @@ URL → sns_router → get_audio → get_transcription (STT) → get_video_narra - `ocrText`: 비디오 텍스트 (현재 비활성화) - `result`: 최종 추출된 장소들 +## 공통 유틸리티 사용 가이드 + +새로운 기능 구현 시 `src/utils/common.py`의 공통 함수를 우선 사용합니다. + +### HTTP 요청 (외부 API 호출) + +외부 API 호출 시 반드시 `http_get_json()` 사용: + +```python +from src.utils.common import http_get_json + +# ✅ 올바른 사용 +data = await http_get_json( + url="https://api.example.com/data", + params={"query": "value"}, + headers={"Authorization": "Bearer token"}, + timeout=10.0 # 기본 10초 +) + +# ❌ 직접 httpx 사용 금지 +async with httpx.AsyncClient() as client: + response = await client.get(url) # 타임아웃, 예외처리 누락 위험 +``` + +**장점**: +- 타임아웃 자동 적용 (기본 10초) +- 예외 처리 일관성 (TimeoutException, HTTPStatusError, RequestError) +- CustomError로 변환되어 API 레이어에서 처리 용이 + +### API Key 검증 + +```python +from src.utils.common import verify_api_key +from fastapi import Depends + +@router.post("/endpoint") +async def endpoint(api_key: str = Depends(verify_api_key)): + # X-API-Key 헤더 자동 검증 +``` + +### 기타 유틸리티 + +- `validate_url_length()`: URL 길이 검증 +- `mask_sensitive_data()`: 로그 출력 시 민감 데이터 마스킹 +- `convert_to_bytesio()`: bytes/BytesIO 타입 변환 +- `validate_image_stream()`: 이미지 스트림 유효성 검증 + ## 설정 `.env`에 필요한 환경 변수: @@ -104,6 +151,7 @@ URL → sns_router → get_audio → get_transcription (STT) → get_video_narra - `YOUTUBE_API_KEY`: YouTube Data API 키 - `INSTAGRAM_POST_DOC_ID`, `INSTAGRAM_APP_ID`: Instagram API 설정 - `BACKEND_CALLBACK_URL`, `BACKEND_API_KEY`: 콜백 엔드포인트 설정 +- `KAKAO_REST_API_KEY`: 카카오 로컬 API 키 (Geocoding 용) - `SMB_*`: SMB 파일 서버 설정 (선택사항) ## 참고사항 diff --git a/src/apis/geocoding_router.py b/src/apis/geocoding_router.py new file mode 100644 index 0000000..6922f27 --- /dev/null +++ b/src/apis/geocoding_router.py @@ -0,0 +1,42 @@ +"""src.apis.geocoding_router +Geocoding API 라우터 - 주소 → 위도/경도 변환 +""" +import logging + +from fastapi import APIRouter, Depends, HTTPException + +from src.models.geocoding_models import GeocodingRequest, GeocodingResponse +from src.services.geocoding_service import geocode_with_kakao +from src.utils.common import verify_api_key +from src.core.exceptions import CustomError + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api", tags=["Geocoding API"]) + + +@router.post("/geocode", response_model=GeocodingResponse, status_code=200) +async def geocode( + request: GeocodingRequest, + api_key: str = Depends(verify_api_key) +): + """ + 주소를 위도/경도로 변환 (카카오 API) + + - 인증: X-API-Key 헤더 필요 + - POST /api/geocode + - Body: {"address": "서울시 강남구 테헤란로 123"} + - 성공: 200 + GeocodingResponse + - 실패: 404 (주소 못 찾음), 401 (인증 실패) + """ + logger.info(f"Geocoding 요청: address='{request.address}'") + + try: + result = await geocode_with_kakao(request.address) + return GeocodingResponse( + address=request.address, + latitude=result.latitude, + longitude=result.longitude, + provider=result.provider + ) + except CustomError as error: + raise HTTPException(status_code=404, detail=error.message) diff --git a/src/apis/test_router.py b/src/apis/test_router.py index d7987c8..e74a672 100644 --- a/src/apis/test_router.py +++ b/src/apis/test_router.py @@ -2,12 +2,22 @@ 테스트 API 라우터 - SNS 스크래핑 테스트용 """ import logging -from fastapi import APIRouter +from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field from src.services.scraper.scrape_router import route_and_scrape from src.services.scraper.platforms.naver_map_scraper import NaverMapScraper from src.services.scraper.platforms.google_map_scraper import GoogleMapScraper +from src.models.geocoding_models import ( + GeocodingTestRequest, + GeocodingResponse, + GeocodingProvider +) +from src.services.geocoding_service import ( + geocode_with_kakao, + geocode_with_nominatim +) +from src.core.exceptions import CustomError logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/test", tags=["테스트 API"]) @@ -103,3 +113,32 @@ async def scrape_google_map(request: GoogleMapSearchRequest): logger.info(f"스크래핑 완료: {result.name} ({result.place_id})") return result + + +@router.post("/geocode", response_model=GeocodingResponse, status_code=200) +async def test_geocode(request: GeocodingTestRequest): + """ + 주소를 위도/경도로 변환 (테스트용 - provider 선택 가능) + + - POST /api/test/geocode + - Body: {"address": "서울시 강남구", "provider": "kakao"} + - provider: "kakao" (기본) | "nominatim" + - 성공: 200 + GeocodingResponse + - 실패: 404 (주소 못 찾음) + """ + logger.info(f"Geocoding 테스트 요청: address='{request.address}', provider='{request.provider.value}'") + + try: + if request.provider == GeocodingProvider.KAKAO: + result = await geocode_with_kakao(request.address) + else: + result = await geocode_with_nominatim(request.address) + + return GeocodingResponse( + address=request.address, + latitude=result.latitude, + longitude=result.longitude, + provider=result.provider + ) + except CustomError as error: + raise HTTPException(status_code=404, detail=error.message) \ No newline at end of file diff --git a/src/core/config.py b/src/core/config.py index ed47591..69d39e8 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -13,6 +13,9 @@ class Settings(BaseSettings): BACKEND_API_KEY: str ENVIRONMENT: str = "dev" # dev: 로컬, prod: 서버환경 + # 카카오 API + KAKAO_REST_API_KEY: str + # SMB 설정 SMB_HOST: str = "" SMB_PORT: int = 445 @@ -22,5 +25,5 @@ class Settings(BaseSettings): SMB_REMOTE_DIR: str = "" # 원격 디렉토리 경로 (예: "romrom/images") SMB_DOMAIN: str = "" # 도메인 (선택적) - model_config = SettingsConfigDict(env_file=".env") + model_config = SettingsConfigDict(env_file=".env", extra="ignore") settings = Settings() diff --git a/src/main.py b/src/main.py index 40abf4d..4c72df7 100644 --- a/src/main.py +++ b/src/main.py @@ -8,6 +8,7 @@ from src.core.logging import setup_logging from src.apis.place_router import router as place_router from src.apis.test_router import router as test_router +from src.apis.geocoding_router import router as geocoding_router # 로깅 초기화 setup_logging(log_level="INFO") @@ -47,6 +48,7 @@ async def lifespan(app: FastAPI): # 라우터 등록 app.include_router(place_router) app.include_router(test_router) +app.include_router(geocoding_router) @app.middleware("http") diff --git a/src/models/geocoding_models.py b/src/models/geocoding_models.py new file mode 100644 index 0000000..0dff602 --- /dev/null +++ b/src/models/geocoding_models.py @@ -0,0 +1,33 @@ +"""src.models.geocoding_models +Geocoding API 요청/응답 스키마 +""" +from enum import Enum +from pydantic import BaseModel, Field + + +class GeocodingProvider(str, Enum): + """Geocoding 제공자""" + KAKAO = "kakao" + NOMINATIM = "nominatim" + + +class GeocodingRequest(BaseModel): + """메인 API 요청 (카카오 전용)""" + address: str = Field(..., description="변환할 주소", min_length=1) + + +class GeocodingTestRequest(BaseModel): + """테스트 API 요청 (provider 선택 가능)""" + address: str = Field(..., description="변환할 주소", min_length=1) + provider: GeocodingProvider = Field( + default=GeocodingProvider.KAKAO, + description="Geocoding 제공자 선택" + ) + + +class GeocodingResponse(BaseModel): + """Geocoding 응답""" + address: str = Field(..., description="입력된 주소") + latitude: float = Field(..., description="위도") + longitude: float = Field(..., description="경도") + provider: str = Field(..., description="사용된 제공자") diff --git a/src/services/geocoding_service.py b/src/services/geocoding_service.py new file mode 100644 index 0000000..1702b7a --- /dev/null +++ b/src/services/geocoding_service.py @@ -0,0 +1,120 @@ +"""src.services.geocoding_service +주소 → 위도/경도 변환 (Geocoding) 서비스 +""" +import logging +from dataclasses import dataclass + +from src.core.config import settings +from src.core.exceptions import CustomError +from src.utils.common import http_get_json + +logger = logging.getLogger(__name__) + + +@dataclass +class GeocodingResult: + """Geocoding 결과""" + latitude: float + longitude: float + provider: str + + +async def geocode_with_kakao(address: str) -> GeocodingResult: + """ + 카카오 로컬 API로 Geocoding + + https://developers.kakao.com/docs/latest/ko/local/dev-guide#address-coord + + Args: + address: 변환할 주소 문자열 + + Returns: + GeocodingResult: 위도, 경도, 제공자 정보 + + Raises: + CustomError: 주소를 찾을 수 없거나 API 오류 시 + """ + logger.info(f"카카오 Geocoding 요청: address='{address}'") + + url = "https://dapi.kakao.com/v2/local/search/address.json" + headers = {"Authorization": f"KakaoAK {settings.KAKAO_REST_API_KEY}"} + params = {"query": address} + + data = await http_get_json(url, params=params, headers=headers) + + if data.get("documents"): + document = data["documents"][0] + result = GeocodingResult( + latitude=float(document["y"]), + longitude=float(document["x"]), + provider="kakao" + ) + logger.info(f"카카오 Geocoding 성공: lat={result.latitude}, lon={result.longitude}") + return result + + logger.warning(f"카카오 Geocoding 결과 없음: address='{address}'") + raise CustomError(f"주소를 찾을 수 없습니다: {address}") + + +async def geocode_with_nominatim(address: str) -> GeocodingResult: + """ + Nominatim (OpenStreetMap) API로 Geocoding + + Rate limit: 1 request/second + https://nominatim.org/release-docs/develop/api/Search/ + + Args: + address: 변환할 주소 문자열 + + Returns: + GeocodingResult: 위도, 경도, 제공자 정보 + + Raises: + CustomError: 주소를 찾을 수 없거나 API 오류 시 + """ + logger.info(f"Nominatim Geocoding 요청: address='{address}'") + + url = "https://nominatim.openstreetmap.org/search" + params = {"q": address, "format": "json", "limit": 1} + headers = {"User-Agent": "MapSee-AI/1.0"} + + data = await http_get_json(url, params=params, headers=headers) + + if data: + result = GeocodingResult( + latitude=float(data[0]["lat"]), + longitude=float(data[0]["lon"]), + provider="nominatim" + ) + logger.info(f"Nominatim Geocoding 성공: lat={result.latitude}, lon={result.longitude}") + return result + + logger.warning(f"Nominatim Geocoding 결과 없음: address='{address}'") + raise CustomError(f"주소를 찾을 수 없습니다: {address}") + + +async def geocode_with_fallback(address: str) -> GeocodingResult | None: + """ + Kakao → Nominatim 순서로 Geocoding 시도 (fallback 로직) + + 실패해도 예외를 발생시키지 않고 None 반환 + + Args: + address: 변환할 주소 문자열 + + Returns: + GeocodingResult | None: 성공 시 결과, 모두 실패 시 None + """ + # 1. Kakao API 시도 + try: + return await geocode_with_kakao(address) + except CustomError: + logger.warning(f"카카오 Geocoding 실패, Nominatim으로 fallback: address='{address}'") + + # 2. Nominatim fallback + try: + return await geocode_with_nominatim(address) + except CustomError: + logger.warning(f"Nominatim Geocoding도 실패: address='{address}'") + + return None diff --git a/src/services/scraper/platforms/naver_map_scraper.py b/src/services/scraper/platforms/naver_map_scraper.py index 8c0dae9..7d2473a 100644 --- a/src/services/scraper/platforms/naver_map_scraper.py +++ b/src/services/scraper/platforms/naver_map_scraper.py @@ -19,6 +19,7 @@ MAX_IMAGE_COUNT, ) from src.models.naver_place_info import NaverPlaceInfo +from src.services.geocoding_service import geocode_with_fallback logger = logging.getLogger(__name__) @@ -350,8 +351,20 @@ async def search_and_scrape(self, query: str) -> NaverPlaceInfo: except (TypeError, ValueError): longitude = None - # [7/7] NaverPlaceInfo 생성 - logger.info(f"[7/7] 스크래핑 완료 (좌표: {latitude}, {longitude})") + # [7/8] 좌표 없으면 Geocoding으로 보완 + address_for_geocoding = info.get('address') or info.get('road_address') + if (latitude is None or longitude is None) and address_for_geocoding: + logger.info(f"[7/8] 좌표 없음, Geocoding 시도: address='{address_for_geocoding}'") + geocoding_result = await geocode_with_fallback(address_for_geocoding) + if geocoding_result: + latitude = geocoding_result.latitude + longitude = geocoding_result.longitude + logger.info(f"[7/8] Geocoding 성공 ({geocoding_result.provider}): lat={latitude}, lon={longitude}") + else: + logger.warning("[7/8] Geocoding 실패, 좌표 null 유지") + + # [8/8] NaverPlaceInfo 생성 + logger.info(f"[8/8] 스크래핑 완료 (좌표: {latitude}, {longitude})") return NaverPlaceInfo( place_id=place_id, diff --git a/src/utils/common.py b/src/utils/common.py index de92fe4..27a0e67 100644 --- a/src/utils/common.py +++ b/src/utils/common.py @@ -3,9 +3,13 @@ """ import logging from io import BytesIO -from typing import Union +from typing import Union, Any + +import httpx from fastapi import Header, HTTPException + from src.core.config import settings +from src.core.exceptions import CustomError logger = logging.getLogger(__name__) @@ -43,12 +47,57 @@ def validate_url_length(url: str, max_length: int = 2048) -> None: Raises: CustomError: URL 길이 초과 시 """ - from src.core.exceptions import CustomError - if len(url) > max_length: raise CustomError(f"URL 길이가 {max_length}자를 초과했습니다") +# ============================================================ +# HTTP 클라이언트 유틸리티 +# ============================================================ + +DEFAULT_HTTP_TIMEOUT = 10.0 # 기본 타임아웃 (초) + + +async def http_get_json( + url: str, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + timeout: float = DEFAULT_HTTP_TIMEOUT +) -> dict[str, Any]: + """ + HTTP GET 요청 후 JSON 응답 반환 + + Args: + url: 요청 URL + params: 쿼리 파라미터 + headers: 요청 헤더 + timeout: 타임아웃 (초, 기본 10초) + + Returns: + dict: JSON 응답 + + Raises: + CustomError: 요청 실패 또는 응답 오류 시 + """ + try: + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.get(url, params=params, headers=headers) + response.raise_for_status() + return response.json() + + except httpx.TimeoutException: + logger.error(f"HTTP 요청 타임아웃: url={url}") + raise CustomError(f"요청 시간이 초과되었습니다 ({timeout}초)") + + except httpx.HTTPStatusError as error: + logger.error(f"HTTP 응답 오류: status={error.response.status_code}, url={url}") + raise CustomError(f"API 오류: {error.response.status_code}") + + except httpx.RequestError as error: + logger.error(f"HTTP 연결 실패: url={url}, error={error}") + raise CustomError("API 연결에 실패했습니다") + + def mask_sensitive_data(data: str, show_chars: int = 2) -> str: """ 민감 데이터 마스킹 (로그 출력 시 사용) diff --git a/version.yml b/version.yml index 76b747c..6bd17ea 100644 --- a/version.yml +++ b/version.yml @@ -34,11 +34,11 @@ # - 버전은 항상 높은 버전으로 자동 동기화됩니다 # =================================================================== -version: "0.1.3" -version_code: 16 # app build number +version: "1.0.1" +version_code: 17 # app build number project_type: "python" # spring, flutter, react, react-native, react-native-expo, node, python, basic metadata: - last_updated: "2026-01-18 04:56:18" + last_updated: "2026-01-18 12:29:56" last_updated_by: "Cassiiopeia" default_branch: "main" integrated_from: "SUH-DEVOPS-TEMPLATE"