diff --git a/README.md b/README.md index e95eb65..3790a8c 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,18 @@ -# 부동산 자연어 검색 시스템 +# 부동산 실거래가 검색 시스템 -FastAPI와 OpenAI를 활용한 부동산 자연어 검색 웹 애플리케이션 +FastAPI와 OpenAI를 활용한 부동산 실거래가 검색 웹 애플리케이션 ## 기능 - 자연어로 부동산 조건 입력 - OpenAI API를 통한 자동 정보 추출 -- 추출 정보: +- 국토교통부 공공데이터 API 연동 +- 실제 실거래가 데이터 조회 및 표시 +- 지원 정보: - 매물 형태 (아파트, 오피스텔, 빌라, 주택 등) - 거래 유형 (전세, 월세, 매매) - - 가격 - - 위치 - - 면적 - - 방 개수 + - 지역 자동 인식 및 코드 변환 + - 실거래가 목록 표시 ## 설치 @@ -23,11 +23,24 @@ pip install -r requirements.txt ## 설정 -1. `.env` 파일에 OpenAI API 키 설정: +1. `.env` 파일에 API 키 설정: ``` +# OpenAI API 키 OPENAI_API_KEY=your-actual-api-key + +# 공공데이터 API 키 (국토교통부) +APT_TRADE_API_KEY=your-key # 아파트 매매 +APT_RENT_API_KEY=your-key # 아파트 전월세 +OFFI_TRADE_API_KEY=your-key # 오피스텔 매매 +OFFI_RENT_API_KEY=your-key # 오피스텔 전월세 +SH_TRADE_API_KEY=your-key # 연립/다세대 매매 +SH_RENT_API_KEY=your-key # 연립/다세대 전월세 +HOUSE_TRADE_API_KEY=your-key # 단독/다가구 매매 +HOUSE_RENT_API_KEY=your-key # 단독/다가구 전월세 ``` +2. 공공데이터포털(data.go.kr)에서 각 API 서비스키 발급 + ## 실행 ```bash diff --git a/backend/main.py b/backend/main.py index d61f6b3..f197852 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,4 +1,5 @@ import os +from typing import Optional from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles @@ -8,7 +9,7 @@ import uvicorn from models import RealEstateQuery, ParsedRealEstate from openai_parser import OpenAIParser -from region_converter import RegionCodeConverter +from public_data_api import PublicDataAPIClient load_dotenv() @@ -30,8 +31,8 @@ except ValueError as e: print(f"Warning: {e}") parser = None -# 지역 코드 변환기 초기화 -region_converter = RegionCodeConverter() +# 공공데이터 API 클라이언트 초기화 +public_data_client = PublicDataAPIClient() @app.get("/") async def serve_index(): @@ -49,17 +50,60 @@ async def parse_real_estate(query: RealEstateQuery): try: result = await parser.parse_real_estate_query(query.text) - - # 위치 정보를 시군구 코드로 변환 - if result.location: - region_code, region_name = region_converter.get_region_code(result.location) - result.region_code = region_code - result.region_name = region_name - return result except Exception as e: raise HTTPException(status_code=500, detail=str(e)) +@app.get("/api/real-estate-data") +async def get_real_estate_listings( + property_type: str = "아파트", + transaction_type: str = "매매", + region_code: str = "11680", + deal_ymd: Optional[str] = None +): + """공공데이터 API를 통한 실거래가 조회""" + try: + data = public_data_client.get_real_estate_data( + property_type=property_type, + transaction_type=transaction_type, + region_code=region_code, + deal_ymd=deal_ymd + ) + return {"data": data, "count": len(data)} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.post("/api/search") +async def search_real_estate(query: RealEstateQuery): + """자연어 검색 후 실거래가 데이터 조회""" + if not parser: + raise HTTPException( + status_code=500, + detail="OpenAI API key not configured" + ) + + try: + # 1. 자연어 파싱 + parsed = await parser.parse_real_estate_query(query.text) + + # 2. 실거래가 데이터 조회 + listings = [] + if parsed.region_code and parsed.property_type and parsed.transaction_type: + listings = public_data_client.get_real_estate_data( + property_type=parsed.property_type, + transaction_type=parsed.transaction_type, + region_code=parsed.region_code + ) + print(listings) + + return { + "parsed": parsed, + "listings": listings, + "count": len(listings) + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + # 정적 파일 서빙 (CSS, JS) app.mount("/static", StaticFiles(directory="../frontend"), name="static") diff --git a/backend/openai_parser.py b/backend/openai_parser.py index 9c8028e..75a07a7 100644 --- a/backend/openai_parser.py +++ b/backend/openai_parser.py @@ -3,9 +3,69 @@ import json from openai import OpenAI from dotenv import load_dotenv from models import ParsedRealEstate +from typing import Optional, Tuple load_dotenv() +def get_region_code(location: str) -> Tuple[Optional[str], Optional[str]]: + """위치 정보를 시군구 코드로 변환""" + + # 지역 코드 데이터 로드 + base_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + with open(os.path.join(base_path, 'data', 'region_codes.json'), 'r', encoding='utf-8') as f: + region_codes = json.load(f) + + if not location: + return None, None + + location = location.strip() + + # 1. 정확한 매칭 시도 (전체 이름) + for full_name, code in region_codes.items(): + if location in full_name or full_name in location: + return code, full_name + + # 2. 부분 매칭 시도 (구, 시, 군 단위) + location_parts = location.split() + for part in location_parts: + if part.endswith('구') or part.endswith('시') or part.endswith('군'): + for full_name, code in region_codes.items(): + if part in full_name: + return code, full_name + + # 3. 동 이름으로 구 추정 + dong_to_gu = { + # 서울 강남구 + '역삼': '강남구', '삼성': '강남구', '청담': '강남구', '논현': '강남구', '대치': '강남구', + '도곡': '강남구', '개포': '강남구', '일원': '강남구', '수서': '강남구', '세곡': '강남구', + # 서울 서초구 + '반포': '서초구', '서초': '서초구', '방배': '서초구', '양재': '서초구', '잠원': '서초구', + # 서울 송파구 + '잠실': '송파구', '신천': '송파구', '석촌': '송파구', '송파': '송파구', '가락': '송파구', + '문정': '송파구', '장지': '송파구', '방이': '송파구', '오금': '송파구', + # 서울 강동구 + '천호': '강동구', '성내': '강동구', '길동': '강동구', '둔촌': '강동구', '암사': '강동구', + # 서울 마포구 + '홍대': '마포구', '합정': '마포구', '상수': '마포구', '망원': '마포구', '연남': '마포구', + # 서울 성동구 + '성수': '성동구', '왕십리': '성동구', '행당': '성동구', '금호': '성동구', '옥수': '성동구', + # 경기도 성남시 분당구 + '판교': '분당구', '정자': '분당구', '서현': '분당구', '수내': '분당구', '야탑': '분당구', + '분당': '분당구', '이매': '분당구', + # 경기도 용인시 + '동백': '기흥구', '보정': '기흥구', '죽전': '수지구', '수지': '수지구', + # 경기도 고양시 + '일산': '일산동구', '백석': '일산동구', '마두': '일산서구', '주엽': '일산서구' + } + + for dong, gu in dong_to_gu.items(): + if dong in location: + for full_name, code in region_codes.items(): + if gu in full_name: + return code, full_name + + return None, None + class OpenAIParser: """OpenAI를 이용한 부동산 정보 파싱""" @@ -46,6 +106,12 @@ class OpenAIParser: result = json.loads(response.choices[0].message.content) + # 위치 정보를 시군구 코드로 변환 + region_code = None + region_name = None + if result.get("location"): + region_code, region_name = get_region_code(result.get("location")) + return ParsedRealEstate( price=result.get("price"), location=result.get("location"), @@ -53,8 +119,8 @@ class OpenAIParser: rooms=result.get("rooms"), transaction_type=result.get("transaction_type"), property_type=result.get("property_type"), - region_code=None, # 메인 서버에서 변환 - region_name=None, # 메인 서버에서 변환 + region_code=region_code, + region_name=region_name, raw_text=text ) diff --git a/backend/public_data_api.py b/backend/public_data_api.py new file mode 100644 index 0000000..6bf7a9c --- /dev/null +++ b/backend/public_data_api.py @@ -0,0 +1,236 @@ +import os +import requests +import xml.etree.ElementTree as ET +from datetime import datetime +from typing import List, Dict, Optional +from dotenv import load_dotenv + +load_dotenv() + +class PublicDataAPIClient: + """국토교통부 공공데이터 API 클라이언트""" + + def __init__(self): + """API 키 및 기본 설정 초기화""" + self.base_url = "http://apis.data.go.kr/1613000" + + # API 엔드포인트 매핑 + self.endpoints = { + # 아파트 + ("아파트", "매매"): { + "url": f"{self.base_url}/RTMSDataSvcAptTrade/getRTMSDataSvcAptTrade", + "key": os.getenv("APT_TRADE_API_KEY") + }, + ("아파트", "전세"): { + "url": f"{self.base_url}/RTMSDataSvcAptRent/getRTMSDataSvcAptRent", + "key": os.getenv("APT_RENT_API_KEY") + }, + ("아파트", "월세"): { + "url": f"{self.base_url}/RTMSDataSvcAptRent/getRTMSDataSvcAptRent", + "key": os.getenv("APT_RENT_API_KEY") + }, + # 오피스텔 + ("오피스텔", "매매"): { + "url": f"{self.base_url}/RTMSDataSvcOffiTrade/getRTMSDataSvcOffiTrade", + "key": os.getenv("OFFI_TRADE_API_KEY") + }, + ("오피스텔", "전세"): { + "url": f"{self.base_url}/RTMSDataSvcOffiRent/getRTMSDataSvcOffiRent", + "key": os.getenv("OFFI_RENT_API_KEY") + }, + ("오피스텔", "월세"): { + "url": f"{self.base_url}/RTMSDataSvcOffiRent/getRTMSDataSvcOffiRent", + "key": os.getenv("OFFI_RENT_API_KEY") + }, + # 빌라/연립 + ("빌라", "매매"): { + "url": f"{self.base_url}/RTMSDataSvcSHTrade/getRTMSDataSvcSHTrade", + "key": os.getenv("SH_TRADE_API_KEY") + }, + ("빌라", "전세"): { + "url": f"{self.base_url}/RTMSDataSvcSHRent/getRTMSDataSvcSHRent", + "key": os.getenv("SH_RENT_API_KEY") + }, + ("빌라", "월세"): { + "url": f"{self.base_url}/RTMSDataSvcSHRent/getRTMSDataSvcSHRent", + "key": os.getenv("SH_RENT_API_KEY") + }, + ("연립주택", "매매"): { + "url": f"{self.base_url}/RTMSDataSvcSHTrade/getRTMSDataSvcSHTrade", + "key": os.getenv("SH_TRADE_API_KEY") + }, + ("연립주택", "전세"): { + "url": f"{self.base_url}/RTMSDataSvcSHRent/getRTMSDataSvcSHRent", + "key": os.getenv("SH_RENT_API_KEY") + }, + # 주택 + ("주택", "매매"): { + "url": f"{self.base_url}/RTMSDataSvcSHHouseTrade/getRTMSDataSvcSHHouseTrade", + "key": os.getenv("HOUSE_TRADE_API_KEY") + }, + ("주택", "전세"): { + "url": f"{self.base_url}/RTMSDataSvcSHHouseRent/getRTMSDataSvcSHHouseRent", + "key": os.getenv("HOUSE_RENT_API_KEY") + }, + ("단독주택", "매매"): { + "url": f"{self.base_url}/RTMSDataSvcSHHouseTrade/getRTMSDataSvcSHHouseTrade", + "key": os.getenv("HOUSE_TRADE_API_KEY") + }, + ("다가구주택", "매매"): { + "url": f"{self.base_url}/RTMSDataSvcSHHouseTrade/getRTMSDataSvcSHHouseTrade", + "key": os.getenv("HOUSE_TRADE_API_KEY") + } + } + + def get_real_estate_data( + self, + property_type: str, + transaction_type: str, + region_code: str, + deal_ymd: Optional[str] = None + ) -> List[Dict]: + """ + 부동산 실거래가 데이터 조회 + + Args: + property_type: 매물 형태 (아파트, 오피스텔, 빌라 등) + transaction_type: 거래 유형 (매매, 전세, 월세) + region_code: 지역 코드 (5자리) + deal_ymd: 계약년월 (YYYYMM 형식, 기본값: 현재 월) + + Returns: + 실거래가 데이터 리스트 + """ + + # 기본값 설정 + if not deal_ymd: + deal_ymd = datetime.now().strftime("%Y%m") + + # 매물 형태 정규화 + property_type = self._normalize_property_type(property_type) + transaction_type = self._normalize_transaction_type(transaction_type) + print(property_type) + print(transaction_type) + + # 엔드포인트 찾기 + endpoint_info = self.endpoints.get((property_type, transaction_type)) + if not endpoint_info: + return [] + + if not endpoint_info["key"] or endpoint_info["key"] == "your-api-key-here": + print(f"Warning: API key not configured for {property_type} {transaction_type}") + return [] + + # API 호출 + params = { + "serviceKey": endpoint_info["key"], + "LAWD_CD": region_code, + "DEAL_YMD": deal_ymd, + "pageNo": 1, + "numOfRows": 100 + } + + try: + response = requests.get(endpoint_info["url"], params=params, timeout=10) + response.raise_for_status() + + # XML 파싱 + return self._parse_xml_response(response.text, property_type, transaction_type) + + except Exception as e: + print(f"Error fetching data: {e}") + return [] + + def _normalize_property_type(self, property_type: str) -> str: + """매물 형태 정규화""" + if not property_type: + return "아파트" + + property_type = property_type.strip().lower() + + # 매핑 테이블 + mappings = { + "아파트": ["아파트", "apt", "apartment"], + "오피스텔": ["오피스텔", "officetel", "오피스"], + "빌라": ["빌라", "villa", "빌딩"], + "연립주택": ["연립", "연립주택"], + "주택": ["주택", "house"], + "단독주택": ["단독", "단독주택"], + "다가구주택": ["다가구", "다가구주택"] + } + + for normalized, variants in mappings.items(): + for variant in variants: + if variant in property_type: + return normalized + + return "아파트" # 기본값 + + def _normalize_transaction_type(self, transaction_type: str) -> str: + """거래 유형 정규화""" + if not transaction_type: + return "매매" + + transaction_type = transaction_type.strip().lower() + + if "매매" in transaction_type or "매도" in transaction_type: + return "매매" + elif "전세" in transaction_type: + return "전세" + elif "월세" in transaction_type or "렌트" in transaction_type: + return "월세" + + return "매매" # 기본값 + + def _parse_xml_response(self, xml_text: str, property_type: str, transaction_type: str) -> List[Dict]: + """XML 응답 파싱""" + try: + root = ET.fromstring(xml_text) + + # 응답 코드 확인 + result_code = root.find(".//resultCode") + + items = root.findall(".//item") + results = [] + + for item in items: + data = {} + + # 공통 필드 + data["거래유형"] = transaction_type + data["매물형태"] = property_type + data["년"] = self._get_xml_value(item, "dealYear") + data["월"] = self._get_xml_value(item, "dealMonth") + data["일"] = self._get_xml_value(item, "dealDay") + data["법정동"] = self._get_xml_value(item, "umdNm") + data["지번"] = self._get_xml_value(item, "jibun") + data["전용면적"] = self._get_xml_value(item, "excluUseAr") + data["층"] = self._get_xml_value(item, "floor") + + # 아파트/오피스텔 특화 필드 + if property_type in ["아파트", "오피스텔"]: + data["아파트"] = self._get_xml_value(item, "aptNm") + data["건축년도"] = self._get_xml_value(item, "buildYear") + + # 전월세 특화 필드 + if transaction_type in ["전세", "월세"]: + data["보증금액"] = self._get_xml_value(item, "deposit") + data["월세금액"] = self._get_xml_value(item, "monthlyRent") + + if transaction_type in ["매매"]: + data["거래금액"] = self._get_xml_value(item, "dealAmount") + + results.append(data) + + return results + + except Exception as e: + print(f"Error parsing XML: {e}") + return [] + + def _get_xml_value(self, element, tag: str) -> Optional[str]: + """XML 요소에서 값 추출""" + found = element.find(tag) + if found is not None and found.text: + return found.text.strip() + return None \ No newline at end of file diff --git a/backend/region_converter.py b/backend/region_converter.py deleted file mode 100644 index 52dd912..0000000 --- a/backend/region_converter.py +++ /dev/null @@ -1,124 +0,0 @@ -import json -import os -from typing import Optional, Tuple - -class RegionCodeConverter: - """위치 정보를 시군구 코드로 변환하는 유틸리티""" - - def __init__(self): - """지역 코드 데이터 로드""" - base_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - - # 전체 지역 코드 로드 - with open(os.path.join(base_path, 'data', 'region_codes.json'), 'r', encoding='utf-8') as f: - self.full_codes = json.load(f) - - # 간략 지역 코드 로드 - with open(os.path.join(base_path, 'data', 'region_codes_simple.json'), 'r', encoding='utf-8') as f: - self.simple_codes = json.load(f) - - def get_region_code(self, location: str) -> Tuple[Optional[str], Optional[str]]: - """ - 위치 정보를 시군구 코드로 변환 - - Args: - location: 위치 텍스트 (예: "강남구", "서울 강남", "서울특별시 강남구") - - Returns: - (지역코드, 매칭된 지역명) 튜플 - """ - if not location: - return None, None - - location = location.strip() - - # 1. 정확한 매칭 시도 (전체 이름) - for full_name, code in self.full_codes.items(): - if location in full_name or full_name in location: - return code, full_name - - # 2. 간략 이름으로 매칭 시도 - for simple_name, code in self.simple_codes.items(): - if simple_name in location: - # 전체 이름 찾기 - for full_name, full_code in self.full_codes.items(): - if full_code == code: - return code, full_name - return code, simple_name - - # 3. 부분 매칭 시도 (구, 시, 군 단위) - location_parts = location.split() - for part in location_parts: - # 구/시/군으로 끝나는 부분 찾기 - if part.endswith('구') or part.endswith('시') or part.endswith('군'): - # 간략 코드에서 찾기 - if part in self.simple_codes: - code = self.simple_codes[part] - # 전체 이름 찾기 - for full_name, full_code in self.full_codes.items(): - if full_code == code and part in full_name: - return code, full_name - return code, part - - # 전체 코드에서 찾기 - for full_name, code in self.full_codes.items(): - if part in full_name: - return code, full_name - - # 4. 동 이름으로 구 추정 (주요 동 매핑) - dong_to_gu = { - # 서울 강남구 - '역삼': '강남구', '삼성': '강남구', '청담': '강남구', '논현': '강남구', '대치': '강남구', - '도곡': '강남구', '개포': '강남구', '일원': '강남구', '수서': '강남구', '세곡': '강남구', - # 서울 서초구 - '반포': '서초구', '서초': '서초구', '방배': '서초구', '양재': '서초구', '잠원': '서초구', - '우면': '서초구', '내곡': '서초구', - # 서울 송파구 - '잠실': '송파구', '신천': '송파구', '석촌': '송파구', '송파': '송파구', '가락': '송파구', - '문정': '송파구', '장지': '송파구', '방이': '송파구', '오금': '송파구', '풍납': '송파구', - # 서울 강동구 - '천호': '강동구', '성내': '강동구', '길동': '강동구', '둔촌': '강동구', '암사': '강동구', - '명일': '강동구', '고덕': '강동구', '상일': '강동구', - # 서울 노원구 - '상계': '노원구', '중계': '노원구', '하계': '노원구', '월계': '노원구', '공릉': '노원구', - # 서울 양천구 - '목동': '양천구', '신정': '양천구', '신월': '양천구', - # 서울 영등포구 - '여의도': '영등포구', '당산': '영등포구', '영등포': '영등포구', '문래': '영등포구', - '양평': '영등포구', '신길': '영등포구', '대림': '영등포구', - # 서울 마포구 - '홍대': '마포구', '합정': '마포구', '상수': '마포구', '망원': '마포구', '연남': '마포구', - '서교': '마포구', '공덕': '마포구', '아현': '마포구', '도화': '마포구', '용강': '마포구', - # 서울 성동구 - '성수': '성동구', '왕십리': '성동구', '행당': '성동구', '금호': '성동구', '옥수': '성동구', - # 서울 용산구 - '이태원': '용산구', '한남': '용산구', '동빙고': '용산구', '서빙고': '용산구', '이촌': '용산구', - '한강로': '용산구', '효창': '용산구', '용문': '용산구', - # 서울 종로구 - '광화문': '종로구', '종로': '종로구', '인사동': '종로구', '삼청': '종로구', '평창': '종로구', - '부암': '종로구', '무악': '종로구', '교남': '종로구', - # 서울 중구 - '명동': '중구', '충무로': '중구', '을지로': '중구', '남대문': '중구', '회현': '중구', - '필동': '중구', '장충': '중구', '신당': '중구', '다산': '중구', - # 경기도 성남시 분당구 - '판교': '분당구', '정자': '분당구', '서현': '분당구', '수내': '분당구', '야탑': '분당구', - '이매': '분당구', '분당': '분당구', '구미': '분당구', '금곡': '분당구', - # 경기도 용인시 - '동백': '기흥구', '보정': '기흥구', '죽전': '수지구', '수지': '수지구', - # 경기도 수원시 - '영통': '영통구', '광교': '영통구', '원천': '영통구', '매탄': '영통구', - # 경기도 고양시 - '일산': '일산동구', '백석': '일산동구', '마두': '일산서구', '주엽': '일산서구' - } - - for dong, gu in dong_to_gu.items(): - if dong in location: - if gu in self.simple_codes: - code = self.simple_codes[gu] - # 전체 이름 찾기 - for full_name, full_code in self.full_codes.items(): - if full_code == code: - return code, full_name - return code, gu - - return None, None \ No newline at end of file diff --git a/docs/project_logs.txt b/docs/project_logs.txt index b05fe78..11b7d68 100644 --- a/docs/project_logs.txt +++ b/docs/project_logs.txt @@ -47,6 +47,63 @@ - docs/project_plan.md 업데이트 (API 가이드 문서 추가) - 웹 검색 및 브라우저를 통한 실제 API 정보 수집 +[2025-08-19 14:25:44] 프론트엔드 실거래가 표시 기능 변경 완료 +- frontend/script.js 수정 + - /api/parse에서 /api/search로 엔드포인트 변경 + - displayResults 함수 전면 재작성 + - createListingCard 함수 추가 (실거래가 카드 생성) + - 거래금액 억/만원 단위 변환 + - 면적 평수 자동 계산 + - 건축년도 경과년수 표시 +- frontend/style.css 수정 + - 검색 요약 스타일 추가 + - 실거래가 카드 스타일 추가 + - 그리드 레이아웃 적용 + - 호버 효과 추가 +- frontend/index.html 수정 + - 제목 및 설명 텍스트 업데이트 + - 로딩 메시지 변경 +- README.md 업데이트 + - 실거래가 검색 시스템으로 설명 변경 + - API 키 설정 방법 상세화 +- 표시되는 정보: + - 아파트명/주소 + - 거래금액/보증금/월세 + - 전용면적 (㎡/평) + - 층수 + - 거래일자 + - 건축년도 + - 상세 주소 + +[2025-08-19 14:22:27] 프론트엔드 실거래가 표시 기능 변경 시작 +- 파싱 정보 대신 실제 API 조회 결과 표시 +- 실거래가 목록 표시 UI 구현 + +[2025-08-19 14:06:53] 리팩토링 및 공공데이터 API 연동 작업 완료 +- backend/region_converter.py 삭제 +- backend/openai_parser.py 수정 + - get_region_code 함수 통합 + - 간략 매칭 제거 + - 동 이름 매핑으로 구 추정 +- backend/public_data_api.py 생성 + - PublicDataAPIClient 클래스 + - 국토교통부 API 엔드포인트 매핑 + - 아파트, 오피스텔, 빌라, 주택 매매/전월세 지원 + - XML 응답 파싱 +- backend/main.py 수정 + - /api/real-estate-data 엔드포인트 추가 (실거래가 조회) + - /api/search 엔드포인트 추가 (자연어 검색 + 실거래가) +- .env 파일 수정 + - 각 API별 개별 서비스키 설정 가능 + - APT_TRADE_API_KEY, APT_RENT_API_KEY 등 +- requirements.txt 수정 + - requests 패키지 추가 + +[2025-08-19 14:00:38] 리팩토링 및 공공데이터 API 연동 작업 시작 +- region_converter를 openai_parser에 통합 +- 간략 매칭 제거 +- 공공데이터 API 연동 코드 작성 + [2025-08-19 13:40:37] 위치 정보를 시군구 코드로 변환하는 기능 추가 완료 - backend/region_converter.py 생성 - RegionCodeConverter 클래스 구현 diff --git a/frontend/index.html b/frontend/index.html index 169fa16..01c7ecc 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -9,8 +9,8 @@
-

🏠 부동산 자연어 검색

-

원하시는 부동산 조건을 자유롭게 입력해주세요

+

🏠 부동산 실거래가 검색

+

원하시는 부동산 조건을 자유롭게 입력하면 실거래가를 조회합니다

@@ -34,13 +34,13 @@
diff --git a/frontend/script.js b/frontend/script.js index b6488bf..16839eb 100644 --- a/frontend/script.js +++ b/frontend/script.js @@ -18,7 +18,8 @@ async function parseQuery() { document.getElementById('searchBtn').disabled = true; try { - const response = await fetch('/api/parse', { + // /api/search 엔드포인트 호출 (파싱 + 실거래가 조회) + const response = await fetch('/api/search', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -48,58 +49,158 @@ function displayResults(data) { let html = ''; - if (data.property_type) { - html += `
- 매물 형태: ${data.property_type} -
`; - } - - if (data.transaction_type) { - html += `
- 거래 유형: ${data.transaction_type} -
`; - } - - if (data.price) { - html += `
- 가격: ${data.price} -
`; - } - - if (data.location) { - html += `
- 위치: ${data.location} -
`; + // 파싱된 정보 요약 + if (data.parsed) { + html += '
'; + html += '

🔍 검색 조건

'; + html += '
'; - // 지역 코드가 있으면 표시 - if (data.region_code) { - html += `
- 지역 코드: ${data.region_code} - ${data.region_name ? ` (${data.region_name})` : ''} -
`; + if (data.parsed.property_type) { + html += `${data.parsed.property_type}`; } + if (data.parsed.transaction_type) { + html += `${data.parsed.transaction_type}`; + } + if (data.parsed.location) { + html += `${data.parsed.location}`; + } + if (data.parsed.region_name) { + html += `${data.parsed.region_name}`; + } + + html += '
'; + html += '
'; } - if (data.area) { - html += `
- 면적: ${data.area} -
`; - } - - if (data.rooms) { - html += `
- 방 개수: ${data.rooms}개 -
`; - } - - if (!html) { - html = '

추출된 정보가 없습니다. 더 구체적으로 입력해주세요.

'; + // 실거래가 목록 + if (data.listings && data.listings.length > 0) { + html += '
'; + html += `

📊 실거래가 정보 (${data.count}건)

`; + html += '
'; + + // 최대 20개만 표시 + const displayCount = Math.min(data.listings.length, 20); + for (let i = 0; i < displayCount; i++) { + const item = data.listings[i]; + html += createListingCard(item); + } + + if (data.listings.length > 20) { + html += `

... 외 ${data.listings.length - 20}건

`; + } + + html += '
'; + html += '
'; + } else { + html += '
'; + html += '

😔 검색 결과가 없습니다.

'; + + if (!data.parsed.region_code) { + html += '

지역 정보를 더 구체적으로 입력해주세요.

'; + } else { + html += '

다른 조건으로 검색해보세요.

'; + } + html += '
'; } resultContent.innerHTML = html; document.getElementById('resultSection').style.display = 'block'; } +// 실거래가 카드 생성 +function createListingCard(item) { + let html = '
'; + + // 제목 (아파트명 또는 주소) + const title = item['아파트'] || item['법정동'] || '정보 없음'; + html += `

${title}

`; + + // 거래 정보 + html += '
'; + + // 거래금액 + if (item['거래금액']) { + const price = item['거래금액'].replace(/,/g, '').trim(); + const priceNum = parseInt(price); + const priceText = priceNum >= 10000 ? + `${(priceNum / 10000).toFixed(1)}억` : + `${priceNum.toLocaleString()}만원`; + html += `
+ 거래금액 + ${priceText} +
`; + } + + // 보증금 (전세/월세) + if (item['보증금액']) { + const deposit = item['보증금액'].replace(/,/g, '').trim(); + const depositNum = parseInt(deposit); + const depositText = depositNum >= 10000 ? + `${(depositNum / 10000).toFixed(1)}억` : + `${depositNum.toLocaleString()}만원`; + html += `
+ 보증금 + ${depositText} +
`; + } + + // 월세 + if (item['월세금액'] && item['월세금액'] !== '0') { + html += `
+ 월세 + ${item['월세금액']}만원 +
`; + } + + // 면적 + if (item['전용면적']) { + const area = parseFloat(item['전용면적']); + const pyeong = (area / 3.3).toFixed(1); + html += `
+ 면적 + ${area}㎡ (${pyeong}평) +
`; + } + + // 층 + if (item['층']) { + html += `
+ + ${item['층']}층 +
`; + } + + // 거래일 + if (item['년'] && item['월'] && item['일']) { + html += `
+ 거래일 + ${item['년']}.${item['월']}.${item['일']} +
`; + } + + // 건축년도 + if (item['건축년도']) { + const age = new Date().getFullYear() - parseInt(item['건축년도']); + html += `
+ 건축년도 + ${item['건축년도']}년 (${age}년) +
`; + } + + // 주소 + if (item['법정동'] && item['지번']) { + html += `
+ 주소 + ${item['법정동']} ${item['지번']} +
`; + } + + html += '
'; + html += '
'; + + return html; +} + // Enter 키로 검색 document.addEventListener('DOMContentLoaded', function() { document.getElementById('searchInput').addEventListener('keypress', function(e) { diff --git a/frontend/style.css b/frontend/style.css index ff797c0..ea83d19 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -158,4 +158,118 @@ header p { @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } +} + +/* 검색 요약 */ +.search-summary { + background: #f8f9fa; + padding: 15px; + border-radius: 10px; + margin-bottom: 20px; +} + +.search-summary h3 { + color: #333; + font-size: 1.1em; + margin-bottom: 10px; +} + +.summary-items { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.summary-item { + background: #667eea; + color: white; + padding: 5px 12px; + border-radius: 15px; + font-size: 0.9em; +} + +/* 실거래가 목록 */ +.listings-section { + margin-top: 20px; +} + +.listings-section h3 { + color: #333; + margin-bottom: 15px; +} + +.listings-container { + display: grid; + gap: 15px; +} + +.listing-card { + background: white; + border: 1px solid #e0e0e0; + border-radius: 10px; + padding: 15px; + transition: all 0.3s; + box-shadow: 0 2px 4px rgba(0,0,0,0.05); +} + +.listing-card:hover { + box-shadow: 0 4px 12px rgba(0,0,0,0.1); + transform: translateY(-2px); +} + +.listing-card h4 { + color: #667eea; + margin-bottom: 12px; + font-size: 1.1em; +} + +.listing-info { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 10px; +} + +.info-item { + display: flex; + flex-direction: column; + gap: 3px; +} + +.info-item.full-width { + grid-column: 1 / -1; +} + +.info-item .label { + color: #888; + font-size: 0.85em; +} + +.info-item .value { + color: #333; + font-weight: 500; +} + +.info-item .value.price { + color: #667eea; + font-size: 1.1em; + font-weight: bold; +} + +.no-results { + text-align: center; + padding: 40px; + background: #f8f9fa; + border-radius: 10px; +} + +.no-results p { + margin: 10px 0; + color: #666; +} + +.more-info { + text-align: center; + color: #888; + margin-top: 20px; + font-style: italic; } \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 4cb1d0f..8b946eb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ fastapi uvicorn[standard] openai -python-dotenv \ No newline at end of file +python-dotenv +requests +pydantic \ No newline at end of file