Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9
167 lines
7.2 KiB
Python
167 lines
7.2 KiB
Python
"""매물 수집: 국토부 실거래가(합법, market_deals) + 네이버부동산 호가(회색·폴백, listings)."""
|
||
import os
|
||
import re
|
||
import time
|
||
import logging
|
||
import requests
|
||
from datetime import date
|
||
|
||
from .lawd_codes import LAWD_CODES
|
||
from .db import (get_listing_criteria, upsert_listing, upsert_market_deal,
|
||
save_listing_collect_log)
|
||
|
||
logger = logging.getLogger("realestate-lab")
|
||
|
||
MOLIT_KEY = os.getenv("DATA_GO_KR_API_KEY", "")
|
||
NAVER_BASE = "https://new.land.naver.com/api"
|
||
# MOLIT operation ID (스펙 §3, 라이브 확인 필요). house_type → (rent_op, trade_op)
|
||
MOLIT_OPS = {
|
||
"아파트": ("getRTMSDataSvcAptRent", "getRTMSDataSvcAptTradeDev"),
|
||
"오피스텔": ("getRTMSDataSvcOffiRent", "getRTMSDataSvcOffiTrade"),
|
||
"연립다세대": ("getRTMSDataSvcRHRent", "getRTMSDataSvcRHTrade"),
|
||
}
|
||
_HOUSE_TYPE_ALIAS = {"빌라": "연립다세대"}
|
||
|
||
|
||
def _won_to_manwon(s) -> int | None:
|
||
"""'43,000' 또는 '2억 9,000' → 만원 정수."""
|
||
if s is None:
|
||
return None
|
||
t = str(s).replace(",", "").strip()
|
||
if not t:
|
||
return None
|
||
m = re.match(r"(?:(\d+)억)?\s*(\d+)?", t)
|
||
if m and (m.group(1) or m.group(2)):
|
||
eok = int(m.group(1)) if m.group(1) else 0
|
||
man = int(m.group(2)) if m.group(2) else 0
|
||
return eok * 10000 + man
|
||
try:
|
||
return int(float(t))
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def _parse_molit_rent(raw: dict, house_type: str) -> dict:
|
||
deposit = _won_to_manwon(raw.get("deposit"))
|
||
rent = _won_to_manwon(raw.get("monthlyRent"))
|
||
return {"house_type": house_type, "complex_name": (raw.get("aptNm") or raw.get("offiNm") or "").strip(),
|
||
"area": float(raw.get("excluUseAr") or 0) or None, "deal_type": "전세" if not rent else "월세",
|
||
"deposit": deposit, "monthly_rent": rent, "sale_amount": None,
|
||
"deal_ym": f"{raw.get('dealYear','')}{str(raw.get('dealMonth','')).zfill(2)}",
|
||
"floor": str(raw.get("floor") or ""), "source": "molit"}
|
||
|
||
|
||
def _parse_molit_trade(raw: dict, house_type: str) -> dict:
|
||
return {"house_type": house_type, "complex_name": (raw.get("aptNm") or raw.get("offiNm") or "").strip(),
|
||
"area": float(raw.get("excluUseAr") or 0) or None, "deal_type": "매매",
|
||
"deposit": None, "monthly_rent": None,
|
||
"sale_amount": _won_to_manwon(raw.get("dealAmount")),
|
||
"deal_ym": f"{raw.get('dealYear','')}{str(raw.get('dealMonth','')).zfill(2)}",
|
||
"floor": str(raw.get("floor") or ""), "source": "molit"}
|
||
|
||
|
||
def _parse_naver_article(raw: dict) -> dict:
|
||
dt = raw.get("tradeTypeName") or ""
|
||
prc = _won_to_manwon(raw.get("dealOrWarrantPrc"))
|
||
rent = _won_to_manwon(raw.get("rentPrc"))
|
||
is_sale = dt == "매매"
|
||
return {"article_no": str(raw.get("articleNo") or ""), "source": "naver", "deal_type": dt,
|
||
"deposit": None if is_sale else prc, "monthly_rent": rent,
|
||
"sale_price": prc if is_sale else None,
|
||
"area_exclusive": float(raw.get("area2") or raw.get("areaName") or 0) or None,
|
||
"floor": raw.get("floorInfo"), "complex_name": raw.get("articleName") or raw.get("buildingName"),
|
||
"dong_code": (raw.get("cortarNo") or "")[:5] or None,
|
||
"url": f"https://new.land.naver.com/houses?articleNo={raw.get('articleNo')}",
|
||
"raw_json": None}
|
||
|
||
|
||
def _molit_call(op: str, lawd: str, ym: str) -> list:
|
||
"""국토부 실거래 단건 호출 (기존 collector 재시도 관용). 실패 시 []."""
|
||
if not MOLIT_KEY:
|
||
return []
|
||
url = f"https://apis.data.go.kr/1613000/RTMSDataSvc{op[len('getRTMSDataSvc'):]}/{op}"
|
||
for attempt in range(3):
|
||
try:
|
||
resp = requests.get(url, params={"serviceKey": MOLIT_KEY, "LAWD_CD": lawd,
|
||
"DEAL_YMD": ym, "numOfRows": 200, "_type": "json"}, timeout=30)
|
||
resp.raise_for_status()
|
||
body = resp.json()
|
||
items = (((body.get("response") or {}).get("body") or {}).get("items") or {}).get("item") or []
|
||
return items if isinstance(items, list) else [items]
|
||
except requests.RequestException as e:
|
||
if attempt < 2:
|
||
time.sleep(2 ** attempt)
|
||
else:
|
||
logger.error("MOLIT %s 실패: %s", op, e)
|
||
return []
|
||
|
||
|
||
def _fetch_molit_all() -> int:
|
||
"""대상 동 시군구코드 × 유형 × 최근 2개월 전월세·매매 실거래 수집→market_deals. 저장건수 반환."""
|
||
saved = 0
|
||
lawds = set(LAWD_CODES.values())
|
||
today = date.today()
|
||
yms = [today.strftime("%Y%m"),
|
||
(today.replace(day=1) - __import__("datetime").timedelta(days=1)).strftime("%Y%m")]
|
||
for lawd in lawds:
|
||
for htype, (rent_op, trade_op) in MOLIT_OPS.items():
|
||
for ym in yms:
|
||
for op, parser in ((rent_op, _parse_molit_rent), (trade_op, _parse_molit_trade)):
|
||
for raw in _molit_call(op, lawd, ym):
|
||
try:
|
||
d = parser(raw, htype)
|
||
d["dong_code"] = lawd
|
||
d["dong"] = (raw.get("umdNm") or "").strip() or None
|
||
upsert_market_deal(d)
|
||
saved += 1
|
||
except Exception as e:
|
||
logger.warning("MOLIT 파싱 실패: %s", e)
|
||
time.sleep(0.3)
|
||
return saved
|
||
|
||
|
||
def _fetch_naver_all() -> tuple:
|
||
"""대상 동 네이버 호가 매물 수집→listings. (new_count, total_count) 반환. 예외는 상위로 전파."""
|
||
new_count = total = 0
|
||
crit = get_listing_criteria()
|
||
for dong in crit.get("dongs", []):
|
||
code = LAWD_CODES.get(dong)
|
||
if not code:
|
||
continue
|
||
# 저빈도: 동당 1회, UA/Referer 현실값. 응답 articleList 순회.
|
||
resp = requests.get(f"{NAVER_BASE}/articles",
|
||
params={"cortarNo": code, "order": "dateDesc", "page": 1},
|
||
headers={"User-Agent": "Mozilla/5.0", "Referer": "https://new.land.naver.com/"},
|
||
timeout=20)
|
||
resp.raise_for_status()
|
||
for raw in (resp.json().get("articleList") or []):
|
||
try:
|
||
d = _parse_naver_article(raw)
|
||
if not d["article_no"]:
|
||
continue
|
||
d["dong"] = dong
|
||
_, is_new = upsert_listing(d)
|
||
total += 1
|
||
if is_new:
|
||
new_count += 1
|
||
except Exception as e:
|
||
logger.warning("네이버 파싱 실패: %s", e)
|
||
time.sleep(1.5)
|
||
return new_count, total
|
||
|
||
|
||
def collect_listings() -> dict:
|
||
naver_ok = True
|
||
new_count = total = 0
|
||
try:
|
||
_fetch_molit_all() # 실거래(합법)는 항상 우선 — 안전마진 baseline
|
||
except Exception as e:
|
||
logger.error("MOLIT 수집 오류(안전마진 baseline): %s", e)
|
||
try:
|
||
new_count, total = _fetch_naver_all()
|
||
except requests.RequestException as e:
|
||
naver_ok = False
|
||
logger.warning("네이버 매물수집 skip(차단/오류) — 안전마진·실거래는 유지: %s", e)
|
||
save_listing_collect_log(new_count, total, naver_ok)
|
||
return {"new_count": new_count, "total_count": total, "naver_ok": naver_ok}
|