실거래가 조회 및 출력
parent
5bf283c5d4
commit
3d1f8683a4
29
README.md
29
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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 클래스 구현
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@
|
|||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>🏠 부동산 자연어 검색</h1>
|
||||
<p>원하시는 부동산 조건을 자유롭게 입력해주세요</p>
|
||||
<h1>🏠 부동산 실거래가 검색</h1>
|
||||
<p>원하시는 부동산 조건을 자유롭게 입력하면 실거래가를 조회합니다</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
|
|
@ -34,13 +34,13 @@
|
|||
</div>
|
||||
|
||||
<div id="resultSection" class="result-section" style="display: none;">
|
||||
<h2>📊 분석 결과</h2>
|
||||
<h2>📊 검색 결과</h2>
|
||||
<div id="resultContent"></div>
|
||||
</div>
|
||||
|
||||
<div id="loadingSection" class="loading" style="display: none;">
|
||||
<div class="spinner"></div>
|
||||
<p>AI가 분석 중입니다...</p>
|
||||
<p>실거래가 데이터를 조회 중입니다...</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 += `<div class="result-item">
|
||||
<strong>매물 형태:</strong> ${data.property_type}
|
||||
</div>`;
|
||||
}
|
||||
// 파싱된 정보 요약
|
||||
if (data.parsed) {
|
||||
html += '<div class="search-summary">';
|
||||
html += '<h3>🔍 검색 조건</h3>';
|
||||
html += '<div class="summary-items">';
|
||||
|
||||
if (data.transaction_type) {
|
||||
html += `<div class="result-item">
|
||||
<strong>거래 유형:</strong> ${data.transaction_type}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (data.price) {
|
||||
html += `<div class="result-item">
|
||||
<strong>가격:</strong> ${data.price}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (data.location) {
|
||||
html += `<div class="result-item">
|
||||
<strong>위치:</strong> ${data.location}
|
||||
</div>`;
|
||||
|
||||
// 지역 코드가 있으면 표시
|
||||
if (data.region_code) {
|
||||
html += `<div class="result-item">
|
||||
<strong>지역 코드:</strong> ${data.region_code}
|
||||
${data.region_name ? ` (${data.region_name})` : ''}
|
||||
</div>`;
|
||||
if (data.parsed.property_type) {
|
||||
html += `<span class="summary-item">${data.parsed.property_type}</span>`;
|
||||
}
|
||||
if (data.parsed.transaction_type) {
|
||||
html += `<span class="summary-item">${data.parsed.transaction_type}</span>`;
|
||||
}
|
||||
if (data.parsed.location) {
|
||||
html += `<span class="summary-item">${data.parsed.location}</span>`;
|
||||
}
|
||||
if (data.parsed.region_name) {
|
||||
html += `<span class="summary-item">${data.parsed.region_name}</span>`;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
if (data.area) {
|
||||
html += `<div class="result-item">
|
||||
<strong>면적:</strong> ${data.area}
|
||||
</div>`;
|
||||
}
|
||||
// 실거래가 목록
|
||||
if (data.listings && data.listings.length > 0) {
|
||||
html += '<div class="listings-section">';
|
||||
html += `<h3>📊 실거래가 정보 (${data.count}건)</h3>`;
|
||||
html += '<div class="listings-container">';
|
||||
|
||||
if (data.rooms) {
|
||||
html += `<div class="result-item">
|
||||
<strong>방 개수:</strong> ${data.rooms}개
|
||||
</div>`;
|
||||
}
|
||||
// 최대 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 (!html) {
|
||||
html = '<p>추출된 정보가 없습니다. 더 구체적으로 입력해주세요.</p>';
|
||||
if (data.listings.length > 20) {
|
||||
html += `<p class="more-info">... 외 ${data.listings.length - 20}건</p>`;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
} else {
|
||||
html += '<div class="no-results">';
|
||||
html += '<p>😔 검색 결과가 없습니다.</p>';
|
||||
|
||||
if (!data.parsed.region_code) {
|
||||
html += '<p>지역 정보를 더 구체적으로 입력해주세요.</p>';
|
||||
} else {
|
||||
html += '<p>다른 조건으로 검색해보세요.</p>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
resultContent.innerHTML = html;
|
||||
document.getElementById('resultSection').style.display = 'block';
|
||||
}
|
||||
|
||||
// 실거래가 카드 생성
|
||||
function createListingCard(item) {
|
||||
let html = '<div class="listing-card">';
|
||||
|
||||
// 제목 (아파트명 또는 주소)
|
||||
const title = item['아파트'] || item['법정동'] || '정보 없음';
|
||||
html += `<h4>${title}</h4>`;
|
||||
|
||||
// 거래 정보
|
||||
html += '<div class="listing-info">';
|
||||
|
||||
// 거래금액
|
||||
if (item['거래금액']) {
|
||||
const price = item['거래금액'].replace(/,/g, '').trim();
|
||||
const priceNum = parseInt(price);
|
||||
const priceText = priceNum >= 10000 ?
|
||||
`${(priceNum / 10000).toFixed(1)}억` :
|
||||
`${priceNum.toLocaleString()}만원`;
|
||||
html += `<div class="info-item">
|
||||
<span class="label">거래금액</span>
|
||||
<span class="value price">${priceText}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 보증금 (전세/월세)
|
||||
if (item['보증금액']) {
|
||||
const deposit = item['보증금액'].replace(/,/g, '').trim();
|
||||
const depositNum = parseInt(deposit);
|
||||
const depositText = depositNum >= 10000 ?
|
||||
`${(depositNum / 10000).toFixed(1)}억` :
|
||||
`${depositNum.toLocaleString()}만원`;
|
||||
html += `<div class="info-item">
|
||||
<span class="label">보증금</span>
|
||||
<span class="value">${depositText}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 월세
|
||||
if (item['월세금액'] && item['월세금액'] !== '0') {
|
||||
html += `<div class="info-item">
|
||||
<span class="label">월세</span>
|
||||
<span class="value">${item['월세금액']}만원</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 면적
|
||||
if (item['전용면적']) {
|
||||
const area = parseFloat(item['전용면적']);
|
||||
const pyeong = (area / 3.3).toFixed(1);
|
||||
html += `<div class="info-item">
|
||||
<span class="label">면적</span>
|
||||
<span class="value">${area}㎡ (${pyeong}평)</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 층
|
||||
if (item['층']) {
|
||||
html += `<div class="info-item">
|
||||
<span class="label">층</span>
|
||||
<span class="value">${item['층']}층</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 거래일
|
||||
if (item['년'] && item['월'] && item['일']) {
|
||||
html += `<div class="info-item">
|
||||
<span class="label">거래일</span>
|
||||
<span class="value">${item['년']}.${item['월']}.${item['일']}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 건축년도
|
||||
if (item['건축년도']) {
|
||||
const age = new Date().getFullYear() - parseInt(item['건축년도']);
|
||||
html += `<div class="info-item">
|
||||
<span class="label">건축년도</span>
|
||||
<span class="value">${item['건축년도']}년 (${age}년)</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 주소
|
||||
if (item['법정동'] && item['지번']) {
|
||||
html += `<div class="info-item full-width">
|
||||
<span class="label">주소</span>
|
||||
<span class="value">${item['법정동']} ${item['지번']}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
// Enter 키로 검색
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.getElementById('searchInput').addEventListener('keypress', function(e) {
|
||||
|
|
|
|||
|
|
@ -159,3 +159,117 @@ header p {
|
|||
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;
|
||||
}
|
||||
|
|
@ -2,3 +2,5 @@ fastapi
|
|||
uvicorn[standard]
|
||||
openai
|
||||
python-dotenv
|
||||
requests
|
||||
pydantic
|
||||
Loading…
Reference in New Issue