실거래가 조회 및 출력

master
jaehwang 2025-08-19 14:57:03 +09:00
parent 5bf283c5d4
commit 3d1f8683a4
10 changed files with 701 additions and 192 deletions

View File

@ -1,18 +1,18 @@
# 부동산 자연어 검색 시스템 # 부동산 실거래가 검색 시스템
FastAPI와 OpenAI를 활용한 부동산 자연어 검색 웹 애플리케이션 FastAPI와 OpenAI를 활용한 부동산 실거래가 검색 웹 애플리케이션
## 기능 ## 기능
- 자연어로 부동산 조건 입력 - 자연어로 부동산 조건 입력
- OpenAI API를 통한 자동 정보 추출 - 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 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 ```bash

View File

@ -1,4 +1,5 @@
import os import os
from typing import Optional
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@ -8,7 +9,7 @@ import uvicorn
from models import RealEstateQuery, ParsedRealEstate from models import RealEstateQuery, ParsedRealEstate
from openai_parser import OpenAIParser from openai_parser import OpenAIParser
from region_converter import RegionCodeConverter from public_data_api import PublicDataAPIClient
load_dotenv() load_dotenv()
@ -30,8 +31,8 @@ except ValueError as e:
print(f"Warning: {e}") print(f"Warning: {e}")
parser = None parser = None
# 지역 코드 변환기 초기화 # 공공데이터 API 클라이언트 초기화
region_converter = RegionCodeConverter() public_data_client = PublicDataAPIClient()
@app.get("/") @app.get("/")
async def serve_index(): async def serve_index():
@ -49,17 +50,60 @@ async def parse_real_estate(query: RealEstateQuery):
try: try:
result = await parser.parse_real_estate_query(query.text) 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 return result
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(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) # 정적 파일 서빙 (CSS, JS)
app.mount("/static", StaticFiles(directory="../frontend"), name="static") app.mount("/static", StaticFiles(directory="../frontend"), name="static")

View File

@ -3,9 +3,69 @@ import json
from openai import OpenAI from openai import OpenAI
from dotenv import load_dotenv from dotenv import load_dotenv
from models import ParsedRealEstate from models import ParsedRealEstate
from typing import Optional, Tuple
load_dotenv() 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: class OpenAIParser:
"""OpenAI를 이용한 부동산 정보 파싱""" """OpenAI를 이용한 부동산 정보 파싱"""
@ -46,6 +106,12 @@ class OpenAIParser:
result = json.loads(response.choices[0].message.content) 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( return ParsedRealEstate(
price=result.get("price"), price=result.get("price"),
location=result.get("location"), location=result.get("location"),
@ -53,8 +119,8 @@ class OpenAIParser:
rooms=result.get("rooms"), rooms=result.get("rooms"),
transaction_type=result.get("transaction_type"), transaction_type=result.get("transaction_type"),
property_type=result.get("property_type"), property_type=result.get("property_type"),
region_code=None, # 메인 서버에서 변환 region_code=region_code,
region_name=None, # 메인 서버에서 변환 region_name=region_name,
raw_text=text raw_text=text
) )

236
backend/public_data_api.py Normal file
View File

@ -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

View File

@ -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

View File

@ -47,6 +47,63 @@
- docs/project_plan.md 업데이트 (API 가이드 문서 추가) - docs/project_plan.md 업데이트 (API 가이드 문서 추가)
- 웹 검색 및 브라우저를 통한 실제 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] 위치 정보를 시군구 코드로 변환하는 기능 추가 완료 [2025-08-19 13:40:37] 위치 정보를 시군구 코드로 변환하는 기능 추가 완료
- backend/region_converter.py 생성 - backend/region_converter.py 생성
- RegionCodeConverter 클래스 구현 - RegionCodeConverter 클래스 구현

View File

@ -9,8 +9,8 @@
<body> <body>
<div class="container"> <div class="container">
<header> <header>
<h1>🏠 부동산 자연어 검색</h1> <h1>🏠 부동산 실거래가 검색</h1>
<p>원하시는 부동산 조건을 자유롭게 입력해주세요</p> <p>원하시는 부동산 조건을 자유롭게 입력하면 실거래가를 조회합니다</p>
</header> </header>
<main> <main>
@ -34,13 +34,13 @@
</div> </div>
<div id="resultSection" class="result-section" style="display: none;"> <div id="resultSection" class="result-section" style="display: none;">
<h2>📊 분석 결과</h2> <h2>📊 검색 결과</h2>
<div id="resultContent"></div> <div id="resultContent"></div>
</div> </div>
<div id="loadingSection" class="loading" style="display: none;"> <div id="loadingSection" class="loading" style="display: none;">
<div class="spinner"></div> <div class="spinner"></div>
<p>AI가 분석 중입니다...</p> <p>실거래가 데이터를 조회 중입니다...</p>
</div> </div>
</main> </main>
</div> </div>

View File

@ -18,7 +18,8 @@ async function parseQuery() {
document.getElementById('searchBtn').disabled = true; document.getElementById('searchBtn').disabled = true;
try { try {
const response = await fetch('/api/parse', { // /api/search 엔드포인트 호출 (파싱 + 실거래가 조회)
const response = await fetch('/api/search', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -48,58 +49,158 @@ function displayResults(data) {
let html = ''; let html = '';
if (data.property_type) { // 파싱된 정보 요약
html += `<div class="result-item"> if (data.parsed) {
<strong>매물 형태:</strong> ${data.property_type} html += '<div class="search-summary">';
</div>`; html += '<h3>🔍 검색 조건</h3>';
html += '<div class="summary-items">';
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>`;
} }
if (data.transaction_type) { html += '</div>';
html += `<div class="result-item"> html += '</div>';
<strong>거래 유형:</strong> ${data.transaction_type}
</div>`;
} }
if (data.price) { // 실거래가 목록
html += `<div class="result-item"> if (data.listings && data.listings.length > 0) {
<strong>가격:</strong> ${data.price} html += '<div class="listings-section">';
</div>`; html += `<h3>📊 실거래가 정보 (${data.count}건)</h3>`;
html += '<div class="listings-container">';
// 최대 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.location) { if (data.listings.length > 20) {
html += `<div class="result-item"> html += `<p class="more-info">... 외 ${data.listings.length - 20}건</p>`;
<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.area) { html += '</div>';
html += `<div class="result-item"> html += '</div>';
<strong>면적:</strong> ${data.area} } else {
</div>`; html += '<div class="no-results">';
} html += '<p>😔 검색 결과가 없습니다.</p>';
if (data.rooms) { if (!data.parsed.region_code) {
html += `<div class="result-item"> html += '<p>지역 정보를 더 구체적으로 입력해주세요.</p>';
<strong> 개수:</strong> ${data.rooms} } else {
</div>`; html += '<p>다른 조건으로 검색해보세요.</p>';
} }
html += '</div>';
if (!html) {
html = '<p>추출된 정보가 없습니다. 더 구체적으로 입력해주세요.</p>';
} }
resultContent.innerHTML = html; resultContent.innerHTML = html;
document.getElementById('resultSection').style.display = 'block'; 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 키로 검색 // Enter 키로 검색
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
document.getElementById('searchInput').addEventListener('keypress', function(e) { document.getElementById('searchInput').addEventListener('keypress', function(e) {

View File

@ -159,3 +159,117 @@ header p {
0% { transform: rotate(0deg); } 0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); } 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;
}

View File

@ -2,3 +2,5 @@ fastapi
uvicorn[standard] uvicorn[standard]
openai openai
python-dotenv python-dotenv
requests
pydantic