실거래가 조회 및 출력
parent
5bf283c5d4
commit
3d1f8683a4
29
README.md
29
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 가이드 문서 추가)
|
- 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 클래스 구현
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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.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.parsed.property_type) {
|
||||||
if (data.region_code) {
|
html += `<span class="summary-item">${data.parsed.property_type}</span>`;
|
||||||
html += `<div class="result-item">
|
|
||||||
<strong>지역 코드:</strong> ${data.region_code}
|
|
||||||
${data.region_name ? ` (${data.region_name})` : ''}
|
|
||||||
</div>`;
|
|
||||||
}
|
}
|
||||||
|
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">
|
if (data.listings && data.listings.length > 0) {
|
||||||
<strong>면적:</strong> ${data.area}
|
html += '<div class="listings-section">';
|
||||||
</div>`;
|
html += `<h3>📊 실거래가 정보 (${data.count}건)</h3>`;
|
||||||
}
|
html += '<div class="listings-container">';
|
||||||
|
|
||||||
if (data.rooms) {
|
// 최대 20개만 표시
|
||||||
html += `<div class="result-item">
|
const displayCount = Math.min(data.listings.length, 20);
|
||||||
<strong>방 개수:</strong> ${data.rooms}개
|
for (let i = 0; i < displayCount; i++) {
|
||||||
</div>`;
|
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;
|
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) {
|
||||||
|
|
|
||||||
|
|
@ -158,4 +158,118 @@ header p {
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
fastapi
|
fastapi
|
||||||
uvicorn[standard]
|
uvicorn[standard]
|
||||||
openai
|
openai
|
||||||
python-dotenv
|
python-dotenv
|
||||||
|
requests
|
||||||
|
pydantic
|
||||||
Loading…
Reference in New Issue