feat(realestate): 매물 수집 MOLIT/Naver 진단 계측(collect_log.error) — silent-failure 관측

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9
This commit is contained in:
2026-07-09 17:09:59 +09:00
parent a7be8f76bf
commit cdc309150e
2 changed files with 65 additions and 18 deletions

View File

@@ -75,11 +75,13 @@ def _parse_naver_article(raw: dict) -> dict:
"raw_json": None} "raw_json": None}
def _molit_call(op: str, lawd: str, ym: str) -> list: def _molit_call(op: str, lawd: str, ym: str):
"""국토부 실거래 단건 호출 (기존 collector 재시도 관용). 실패 시 [].""" """국토부 실거래 단건 호출 (재시도). Returns (items, diag).
diag=None on success, else 짧은 진단 문자열(http=401 / json_err / req_err=...) — 관측용."""
if not MOLIT_KEY: if not MOLIT_KEY:
return [] return [], "no_key"
url = f"https://apis.data.go.kr/1613000/RTMSDataSvc{op[len('getRTMSDataSvc'):]}/{op}" url = f"https://apis.data.go.kr/1613000/RTMSDataSvc{op[len('getRTMSDataSvc'):]}/{op}"
last_diag = None
for attempt in range(3): for attempt in range(3):
try: try:
resp = requests.get(url, params={"serviceKey": MOLIT_KEY, "LAWD_CD": lawd, resp = requests.get(url, params={"serviceKey": MOLIT_KEY, "LAWD_CD": lawd,
@@ -87,18 +89,28 @@ def _molit_call(op: str, lawd: str, ym: str) -> list:
resp.raise_for_status() resp.raise_for_status()
body = resp.json() body = resp.json()
items = (((body.get("response") or {}).get("body") or {}).get("items") or {}).get("item") or [] items = (((body.get("response") or {}).get("body") or {}).get("items") or {}).get("item") or []
return items if isinstance(items, list) else [items] return (items if isinstance(items, list) else [items]), None
except requests.HTTPError as e:
code = getattr(getattr(e, "response", None), "status_code", "?")
last_diag = f"http={code}"
except requests.RequestException as e: except requests.RequestException as e:
last_diag = f"req_err={type(e).__name__}"
except ValueError:
# 200이지만 JSON 아님(XML 오류 응답 등) — 재시도 무의미
last_diag = "json_err"
break
if attempt < 2: if attempt < 2:
time.sleep(2 ** attempt) time.sleep(2 ** attempt)
else: logger.error("MOLIT %s 실패: %s", op, last_diag)
logger.error("MOLIT %s 실패: %s", op, e) return [], last_diag
return []
def _fetch_molit_all() -> int: def _fetch_molit_all():
"""대상 동 시군구코드 × 유형 × 최근 2개월 전월세·매매 실거래 수집→market_deals. 저장건수 반환.""" """대상 동 시군구코드 × 유형 × 최근 2개월 전월세·매매 실거래 수집→market_deals.
Returns (saved_count, diag). diag = 'calls=N saved=M [err=<첫에러>]' 관측용."""
saved = 0 saved = 0
calls = 0
first_err = None
lawds = set(LAWD_CODES.values()) lawds = set(LAWD_CODES.values())
today = date.today() today = date.today()
yms = [today.strftime("%Y%m"), yms = [today.strftime("%Y%m"),
@@ -107,7 +119,11 @@ def _fetch_molit_all() -> int:
for htype, (rent_op, trade_op) in MOLIT_OPS.items(): for htype, (rent_op, trade_op) in MOLIT_OPS.items():
for ym in yms: for ym in yms:
for op, parser in ((rent_op, _parse_molit_rent), (trade_op, _parse_molit_trade)): for op, parser in ((rent_op, _parse_molit_rent), (trade_op, _parse_molit_trade)):
for raw in _molit_call(op, lawd, ym): items, diag = _molit_call(op, lawd, ym)
calls += 1
if diag and first_err is None:
first_err = diag
for raw in items:
try: try:
d = parser(raw, htype) d = parser(raw, htype)
d["dong_code"] = lawd d["dong_code"] = lawd
@@ -117,7 +133,8 @@ def _fetch_molit_all() -> int:
except Exception as e: except Exception as e:
logger.warning("MOLIT 파싱 실패: %s", e) logger.warning("MOLIT 파싱 실패: %s", e)
time.sleep(0.3) time.sleep(0.3)
return saved diag = f"calls={calls} saved={saved}" + (f" err={first_err}" if first_err else "")
return saved, diag
def _fetch_naver_all() -> tuple: def _fetch_naver_all() -> tuple:
@@ -153,14 +170,18 @@ def _fetch_naver_all() -> tuple:
def collect_listings() -> dict: def collect_listings() -> dict:
naver_ok = True naver_ok = True
new_count = total = 0 new_count = total = 0
molit_diag = naver_diag = None
try: try:
_fetch_molit_all() # 실거래(합법)는 항상 우선 — 안전마진 baseline _, molit_diag = _fetch_molit_all() # 실거래(합법)는 항상 우선 — 안전마진 baseline
except Exception as e: except Exception as e:
molit_diag = f"exc={type(e).__name__}:{str(e)[:80]}"
logger.error("MOLIT 수집 오류(안전마진 baseline): %s", e) logger.error("MOLIT 수집 오류(안전마진 baseline): %s", e)
try: try:
new_count, total = _fetch_naver_all() new_count, total = _fetch_naver_all()
except requests.RequestException as e: except requests.RequestException as e:
naver_ok = False naver_ok = False
naver_diag = f"{type(e).__name__}:{str(e)[:80]}"
logger.warning("네이버 매물수집 skip(차단/오류) — 안전마진·실거래는 유지: %s", e) logger.warning("네이버 매물수집 skip(차단/오류) — 안전마진·실거래는 유지: %s", e)
save_listing_collect_log(new_count, total, naver_ok) diag = f"molit[{molit_diag}] naver[{naver_diag or 'ok'}]"
return {"new_count": new_count, "total_count": total, "naver_ok": naver_ok} save_listing_collect_log(new_count, total, naver_ok, error=diag)
return {"new_count": new_count, "total_count": total, "naver_ok": naver_ok, "diag": diag}

View File

@@ -1,4 +1,4 @@
from unittest.mock import patch from unittest.mock import patch, MagicMock
def test_parse_molit_rent_fields(): def test_parse_molit_rent_fields():
from app.listing_collector import _parse_molit_rent from app.listing_collector import _parse_molit_rent
@@ -20,10 +20,36 @@ def test_parse_naver_article_fields():
def test_collect_naver_block_sets_naver_ok_false(monkeypatch): def test_collect_naver_block_sets_naver_ok_false(monkeypatch):
"""네이버 403 → 매물수집 skip + naver_ok=False, 실거래는 계속.""" """네이버 403 → 매물수집 skip + naver_ok=False, 실거래는 계속."""
from app import listing_collector as lc from app import listing_collector as lc
monkeypatch.setattr(lc, "_fetch_molit_all", lambda: 2) # 실거래 2건 저장됨 가정 monkeypatch.setattr(lc, "_fetch_molit_all", lambda: (2, "calls=1 saved=2"))
def boom(*a, **k): def boom(*a, **k):
import requests import requests
raise requests.RequestException("403") raise requests.RequestException("403")
monkeypatch.setattr(lc, "_fetch_naver_all", boom) monkeypatch.setattr(lc, "_fetch_naver_all", boom)
r = lc.collect_listings() r = lc.collect_listings()
assert r["naver_ok"] is False # 안전마진(실거래)은 유지 assert r["naver_ok"] is False # 안전마진(실거래)은 유지
def test_molit_call_returns_diag_on_http_error(monkeypatch):
"""MOLIT 401 등 HTTP 오류 → ([], diag) 반환, diag에 상태코드 포함(관측)."""
from app import listing_collector as lc
import requests
monkeypatch.setattr(lc, "MOLIT_KEY", "TESTKEY")
monkeypatch.setattr(lc.time, "sleep", lambda *a: None) # 재시도 대기 skip
resp = MagicMock()
resp.status_code = 401
resp.raise_for_status.side_effect = requests.HTTPError(response=resp)
monkeypatch.setattr(lc.requests, "get", lambda *a, **k: resp)
items, diag = lc._molit_call("getRTMSDataSvcAptTradeDev", "11590", "202606")
assert items == []
assert diag and "401" in diag
def test_collect_writes_molit_diag_to_log(monkeypatch):
"""collect_listings가 MOLIT/Naver 진단을 collect_log.error에 기록(silent-failure 관측)."""
from app import listing_collector as lc
from app import db
monkeypatch.setattr(lc, "_fetch_molit_all", lambda: (0, "calls=36 saved=0 err=http=401"))
monkeypatch.setattr(lc, "_fetch_naver_all", lambda: (0, 0))
lc.collect_listings()
log = db.get_last_listing_collect_log()
assert log is not None
assert "http=401" in (log["error"] or "")
assert "molit" in (log["error"] or "")