docs(realestate): naver 워커 BE 구현 plan

This commit is contained in:
2026-07-10 00:22:11 +09:00
parent e09e11be7b
commit dfda38bd8e

View File

@@ -0,0 +1,467 @@
# 네이버 매물 fetch 워커 — BE 구현 Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** naver-fetch 워커가 네이버 호가 매물을 push할 수 있도록 realestate-lab에 내부 계약(targets 조회 + listings ingest), 관측 등재, cortarNo 매핑을 구축한다. 워커(web-ai)·FE(/infra)는 별도 세션.
**Architecture:** 워커는 `GET /api/internal/realestate/targets`로 대상 동+10자리 cortarNo를 받고, 네이버 raw 매물을 `POST /api/internal/realestate/listings-ingest`로 push. NAS는 기존 `_parse_naver_article`로 파싱→`upsert_listing``run_listing_matching``notify_new_listings`. 인증은 render 워커와 동형(X-Internal-Key + nginx IP화이트리스트). 관측은 heartbeat + node_monitor `fetcher` kind.
**Tech Stack:** Python 3.12, FastAPI(APIRouter+Depends), SQLite, pytest. 외부 호출 테스트는 전부 mock.
**스펙:** `realestate-lab/docs/superpowers/specs/2026-07-09-naver-listing-worker-design.md`
## Global Constraints
- **기존 매물/청약 파이프라인 불변** — 신규 내부 라우터·매핑·등재만 추가. 안전마진 판정 로직 불변.
- **인증 패턴 준수**: `verify_internal_key(x_internal_key: str = Header(...))`, env **`INTERNAL_API_KEY`**, 실패 시 `HTTPException(401, "Invalid X-Internal-Key")`. (스펙의 `REALESTATE_INTERNAL_KEY` 대신 기존 서비스 관례 `INTERNAL_API_KEY` 사용 — image/video/insta-lab 일관성.)
- **파싱은 한 곳**: 네이버 article 파싱은 기존 `listing_collector._parse_naver_article` 재사용(중복 구현 금지).
- **멱등**: `listings.article_no` UNIQUE upsert + `listing_matches.notified_at` → 재push해도 재알림 없음.
- **테스트**: `cd realestate-lab && PYTHONPATH="C:/Users/jaeoh/Desktop/workspace/web-backend" python -m pytest tests/<file> -q` (main import은 `_shared` 때문에 PYTHONPATH에 web-backend 루트 필요). agent-office 테스트는 `cd agent-office && python -m pytest`.
- **cortarNo는 10자리 법정동코드** (기존 `LAWD_CODES` 5자리 시군구는 MOLIT 전용 유지).
- **nginx/deploy는 locked 리소스**: nginx 변경·push는 `nginx-conf`·`nas-deploy` 락 획득 후(Task 6).
## File Structure
| 파일 | 신규/수정 | 책임 |
|---|---|---|
| `realestate-lab/app/lawd_codes.py` | Modify | `NAVER_CORTAR`(동→10자리) + `naver_cortar()` 추가 |
| `realestate-lab/app/auth.py` | Create | `verify_internal_key` (X-Internal-Key) |
| `realestate-lab/app/internal_router.py` | Create | `GET targets` + `POST listings-ingest` |
| `realestate-lab/app/main.py` | Modify | `include_router(internal_router)` |
| `nginx/default.conf` | Modify | `/api/internal/realestate/` 블록 |
| `realestate-lab/app/listing_collector.py` | Modify | `collect_listings` MOLIT 전용화(naver deprecate) |
| `agent-office/app/node_monitor.py` | Modify | `WORKER_REGISTRY` fetcher 등재 + link 분기 |
---
### Task 1: NAVER_CORTAR 매핑 (10자리 법정동코드)
**Files:**
- Modify: `realestate-lab/app/lawd_codes.py`
- Test: `realestate-lab/tests/test_lawd_codes.py` (기존 파일에 append)
**Interfaces:**
- Produces: `NAVER_CORTAR: dict[str,str]`, `naver_cortar(dong: str) -> str | None`
- [ ] **Step 1: 실패 테스트 append**`tests/test_lawd_codes.py`
```python
def test_naver_cortar_10digit():
from app.lawd_codes import naver_cortar, NAVER_CORTAR
assert naver_cortar("신대방동") == "1159010100"
assert naver_cortar("봉천동") == "1162010100"
assert naver_cortar("신길동") == "1156013200"
assert naver_cortar("없는동") is None
# 6개 초기 대상 동 전부 10자리
for d in ["신대방동","대방동","상도동","봉천동","대림동","신길동"]:
assert len(NAVER_CORTAR[d]) == 10
```
- [ ] **Step 2: 실패 확인**`cd realestate-lab && PYTHONPATH="C:/Users/jaeoh/Desktop/workspace/web-backend" python -m pytest tests/test_lawd_codes.py -q` → FAIL(ImportError naver_cortar)
- [ ] **Step 3: 구현**`app/lawd_codes.py` 하단에 추가
```python
# 네이버부동산 cortarNo = 10자리 법정동코드 (LAWD_CODES 5자리 시군구와 별개).
# 출처: 행정표준코드관리시스템 법정동코드. 대상 동 추가 시 여기에.
NAVER_CORTAR = {
"신대방동": "1159010100",
"대방동": "1159010200",
"상도동": "1159010600",
"봉천동": "1162010100",
"대림동": "1156013600",
"신길동": "1156013200",
}
def naver_cortar(dong: str) -> str | None:
return NAVER_CORTAR.get((dong or "").strip())
```
- [ ] **Step 4: 통과 확인** — 같은 pytest → PASS
- [ ] **Step 5: 커밋**
```bash
git add realestate-lab/app/lawd_codes.py realestate-lab/tests/test_lawd_codes.py
git commit -m "feat(realestate): 네이버 cortarNo 10자리 법정동 매핑(NAVER_CORTAR)"
```
---
### Task 2: 내부 인증 + targets 엔드포인트 + 라우터 배선
**Files:**
- Create: `realestate-lab/app/auth.py`, `realestate-lab/app/internal_router.py`
- Modify: `realestate-lab/app/main.py`
- Test: `realestate-lab/tests/test_internal_targets.py`
**Interfaces:**
- Consumes: Task1 `naver_cortar`; 기존 `db.get_listing_criteria`.
- Produces: `verify_internal_key` dependency; `GET /api/internal/realestate/targets``{"dongs":[{"dong","cortar_no"}],"deal_types":[...],"page_limit":int}`; `router` (APIRouter, main에 include).
- [ ] **Step 1: 실패 테스트**`tests/test_internal_targets.py`
```python
import os
from fastapi.testclient import TestClient
def _client(monkeypatch):
monkeypatch.setenv("INTERNAL_API_KEY", "SECRET")
import importlib
from app import auth as _a
importlib.reload(_a) # env 반영
from app.main import app
return TestClient(app)
def test_targets_requires_key(monkeypatch):
c = _client(monkeypatch)
assert c.get("/api/internal/realestate/targets").status_code == 401 # 헤더 없음
assert c.get("/api/internal/realestate/targets",
headers={"X-Internal-Key": "WRONG"}).status_code == 401
def test_targets_returns_dongs_with_cortar(monkeypatch):
c = _client(monkeypatch)
r = c.get("/api/internal/realestate/targets", headers={"X-Internal-Key": "SECRET"})
assert r.status_code == 200
body = r.json()
dongs = {d["dong"]: d["cortar_no"] for d in body["dongs"]}
assert dongs.get("신대방동") == "1159010100" # criteria seed 6동 + cortar join
assert "전세" in body["deal_types"]
assert isinstance(body["page_limit"], int)
```
- [ ] **Step 2: 실패 확인**`PYTHONPATH=... python -m pytest tests/test_internal_targets.py -q` → FAIL(404/ImportError)
- [ ] **Step 3: 구현**
`app/auth.py`:
```python
"""Windows naver-fetch 워커 → NAS realestate-lab 내부 엔드포인트 인증."""
import os
from fastapi import Header, HTTPException
def verify_internal_key(x_internal_key: str = Header(...)):
expected = os.getenv("INTERNAL_API_KEY")
if not expected:
raise HTTPException(401, "INTERNAL_API_KEY not configured on server")
if x_internal_key != expected:
raise HTTPException(401, "Invalid X-Internal-Key")
```
`app/internal_router.py`:
```python
"""naver-fetch 워커 내부 계약: targets 조회 + listings ingest."""
import os
import logging
from fastapi import APIRouter, Depends
from .auth import verify_internal_key
from .db import get_listing_criteria
from .lawd_codes import naver_cortar
logger = logging.getLogger("realestate-lab")
router = APIRouter()
NAVER_PAGE_LIMIT = int(os.getenv("NAVER_PAGE_LIMIT", "2"))
@router.get("/api/internal/realestate/targets", dependencies=[Depends(verify_internal_key)])
def listing_targets():
crit = get_listing_criteria()
dongs = []
for d in crit.get("dongs", []):
code = naver_cortar(d)
if not code:
logger.warning("naver cortarNo 매핑 없음 — 대상 제외: %s", d)
continue
dongs.append({"dong": d, "cortar_no": code})
return {"dongs": dongs, "deal_types": crit.get("deal_types", []),
"page_limit": NAVER_PAGE_LIMIT}
```
`app/main.py`: import 블록에 추가(기존 `from .models import (...)` 아래)
```python
from .internal_router import router as internal_router
```
그리고 `app = FastAPI(lifespan=lifespan)` + `install_access_log(app)` **직후**에:
```python
app.include_router(internal_router)
```
`nginx/default.conf`: `/api/realestate/` location 블록 **앞**에 추가(더 구체적 prefix 우선):
```nginx
location /api/internal/realestate/ {
allow 192.168.45.0/24;
allow 100.64.0.0/10;
allow 127.0.0.1;
deny all;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Internal-Key $http_x_internal_key;
proxy_pass http://realestate-lab:8000/api/internal/realestate/;
}
```
- [ ] **Step 4: 통과 확인** — 같은 pytest → PASS (nginx는 로컬 테스트 무관, 배포 시 검증)
- [ ] **Step 5: 커밋**
```bash
git add realestate-lab/app/auth.py realestate-lab/app/internal_router.py realestate-lab/app/main.py nginx/default.conf realestate-lab/tests/test_internal_targets.py
git commit -m "feat(realestate): 내부 인증 + naver 워커 targets 엔드포인트 + nginx internal 블록"
```
---
### Task 3: listings-ingest 엔드포인트
**Files:**
- Modify: `realestate-lab/app/internal_router.py`
- Test: `realestate-lab/tests/test_internal_ingest.py`
**Interfaces:**
- Consumes: 기존 `db.upsert_listing`, `listing_collector._parse_naver_article`, `listing_matcher.run_listing_matching`, `notifier.notify_new_listings`.
- Produces: `POST /api/internal/realestate/listings-ingest` body `{fetched_at:str, batches:[{dong:str, articles:[dict]}]}``{"received":int,"new":int,"matched":int}`.
- [ ] **Step 1: 실패 테스트**`tests/test_internal_ingest.py`
```python
import os, importlib
from unittest.mock import patch
from fastapi.testclient import TestClient
def _client(monkeypatch):
monkeypatch.setenv("INTERNAL_API_KEY", "SECRET")
from app import auth as _a
importlib.reload(_a)
from app.main import app
return TestClient(app)
_ARTICLE = {"articleNo": "N100", "tradeTypeName": "전세", "dealOrWarrantPrc": "2억 9,000",
"area2": "42.5", "floorInfo": "5/15", "articleName": "OO아파트",
"buildingName": "OO", "cortarNo": "1159010100"}
def test_ingest_auth(monkeypatch):
c = _client(monkeypatch)
assert c.post("/api/internal/realestate/listings-ingest", json={"batches": []}).status_code == 401
def test_ingest_upserts_and_matches(monkeypatch):
c = _client(monkeypatch)
with patch("app.internal_router.notify_new_listings", return_value={"sent": 1, "sent_ids": [1]}) as noti:
r = c.post("/api/internal/realestate/listings-ingest",
headers={"X-Internal-Key": "SECRET"},
json={"fetched_at": "2026-07-09T00:00:00Z",
"batches": [{"dong": "신대방동", "articles": [_ARTICLE]}]})
assert r.status_code == 200
body = r.json()
assert body["received"] == 1 and body["new"] == 1 and body["matched"] == 1
assert noti.call_count == 1
# 저장 확인
from app import db
ls = db.get_listings()
assert any(l["article_no"] == "N100" and l["dong"] == "신대방동" for l in ls)
def test_ingest_idempotent(monkeypatch):
c = _client(monkeypatch)
payload = {"batches": [{"dong": "신대방동", "articles": [_ARTICLE]}]}
with patch("app.internal_router.notify_new_listings", return_value={"sent": 0}):
c.post("/api/internal/realestate/listings-ingest", headers={"X-Internal-Key": "SECRET"}, json=payload)
r2 = c.post("/api/internal/realestate/listings-ingest", headers={"X-Internal-Key": "SECRET"}, json=payload)
assert r2.json()["new"] == 0 # 재push → 신규 0
```
- [ ] **Step 2: 실패 확인**`PYTHONPATH=... python -m pytest tests/test_internal_ingest.py -q` → FAIL(404)
- [ ] **Step 3: 구현**`app/internal_router.py`에 추가
상단 import 확장:
```python
from pydantic import BaseModel
from typing import List, Dict, Any
from .db import get_listing_criteria, upsert_listing
from .listing_collector import _parse_naver_article
from .listing_matcher import run_listing_matching
from .notifier import notify_new_listings
```
(기존 `from .db import get_listing_criteria`는 위 확장 라인으로 합치거나 중복 없이 정리)
모델 + 핸들러:
```python
class _Batch(BaseModel):
dong: str
articles: List[Dict[str, Any]] = []
class ListingsIngest(BaseModel):
fetched_at: str | None = None
batches: List[_Batch] = []
@router.post("/api/internal/realestate/listings-ingest",
dependencies=[Depends(verify_internal_key)])
def listings_ingest(body: ListingsIngest):
received = new = 0
for batch in body.batches:
for raw in batch.articles:
try:
d = _parse_naver_article(raw)
if not d.get("article_no"):
continue
d["dong"] = batch.dong
_, is_new = upsert_listing(d)
received += 1
if is_new:
new += 1
except Exception as e:
logger.warning("네이버 ingest 파싱 실패: %s", e)
matched = 0
if received:
run_listing_matching()
noti = notify_new_listings()
matched = int(noti.get("sent", 0)) if isinstance(noti, dict) else 0
return {"received": received, "new": new, "matched": matched}
```
- [ ] **Step 4: 통과 확인** — 같은 pytest → PASS
- [ ] **Step 5: 커밋**
```bash
git add realestate-lab/app/internal_router.py realestate-lab/tests/test_internal_ingest.py
git commit -m "feat(realestate): naver 워커 listings-ingest(파싱→upsert→매칭→알림)"
```
---
### Task 4: node_monitor fetcher 등재
**Files:**
- Modify: `agent-office/app/node_monitor.py`
- Test: `agent-office/tests/test_node_monitor.py` (기존 파일에 append; 없으면 `test_node_monitor_fetcher.py` 생성)
**Interfaces:**
- Produces: `WORKER_REGISTRY``{"name":"naver-fetch","kind":"fetcher","queue":None}`; `collect_status()`가 fetcher에 대해 `{from:"naver-fetch", to:"nas-realestate", type:"http-pull", status:...}` link 생성.
- [ ] **Step 1: 실패 테스트** — 등재 여부(트리비얼) + 링크 분기
```python
def test_naver_fetch_registered():
from app.node_monitor import WORKER_REGISTRY
entry = next((w for w in WORKER_REGISTRY if w["name"] == "naver-fetch"), None)
assert entry is not None
assert entry["kind"] == "fetcher" and entry["queue"] is None
```
> collect_status의 fetcher-link는 기존 `test_node_monitor.py`의 redis-mock 픽스처(trader/render 링크 검증부)를 그대로 따라, "naver-fetch heartbeat 존재 시 links에 from=naver-fetch·type=http-pull이 있다"를 추가 검증하라. 픽스처가 없으면 이 트리비얼 등재 테스트만으로 충분.
- [ ] **Step 2: 실패 확인**`cd agent-office && python -m pytest tests/test_node_monitor.py -q` (또는 신규 파일) → FAIL
- [ ] **Step 3: 구현**`agent-office/app/node_monitor.py`
`WORKER_REGISTRY` 리스트에 추가:
```python
{"name": "naver-fetch", "kind": "fetcher", "queue": None},
```
`collect_status()`의 kind 분기(`if w["kind"] == "trader": ... elif w["kind"] == "render": ...`)에 추가:
```python
elif w["kind"] == "fetcher":
out["links"].append({"from": w["name"], "to": "nas-realestate", "type": "http-pull",
"status": "healthy" if w["alive"] else "down"})
```
- [ ] **Step 4: 통과 확인** — 같은 pytest → PASS + 회귀 `cd agent-office && python -m pytest -q`
- [ ] **Step 5: 커밋**
```bash
git add agent-office/app/node_monitor.py agent-office/tests/test_node_monitor.py
git commit -m "feat(agent-office): node_monitor에 naver-fetch(fetcher) 등재 + http-pull 링크"
```
---
### Task 5: collect_listings MOLIT 전용화 (naver deprecate)
**Files:**
- Modify: `realestate-lab/app/listing_collector.py`
- Test: `realestate-lab/tests/test_listing_collector.py` (기존 수정)
**Interfaces:**
- `collect_listings()``{"new_count":0,"total_count":0,"naver_ok":None,"diag":str}` (naver는 워커가 담당하므로 NAS는 MOLIT만). `_fetch_naver_all`/`_parse_naver_article`/`_won_to_manwon`**존치**(ingest가 `_parse_naver_article` 사용).
- [ ] **Step 1: 실패 테스트** — 기존 `test_collect_naver_block_sets_naver_ok_false`를 대체
```python
def test_collect_is_molit_only(monkeypatch):
"""collect_listings는 MOLIT만 수행(naver fetch 미호출). naver는 워커 담당."""
from app import listing_collector as lc
monkeypatch.setattr(lc, "_fetch_molit_all", lambda: (5, "calls=1 saved=5"))
called = {"naver": False}
def naver_spy():
called["naver"] = True
return (0, 0)
monkeypatch.setattr(lc, "_fetch_naver_all", naver_spy)
r = lc.collect_listings()
assert called["naver"] is False # naver 미호출
assert "molit" in r["diag"]
```
- [ ] **Step 2: 실패 확인**`PYTHONPATH=... python -m pytest tests/test_listing_collector.py -q` → FAIL(naver 호출됨)
- [ ] **Step 3: 구현**`collect_listings` 교체
```python
def collect_listings() -> dict:
"""MOLIT 실거래(합법 baseline)만 수집. 네이버 호가는 naver-fetch 워커가
/api/internal/realestate/listings-ingest로 push (spec 2026-07-09-naver-listing-worker)."""
molit_diag = None
try:
_, molit_diag = _fetch_molit_all()
except Exception as e:
molit_diag = f"exc={type(e).__name__}:{str(e)[:80]}"
logger.error("MOLIT 수집 오류(안전마진 baseline): %s", e)
diag = f"molit[{molit_diag}] naver[worker]"
save_listing_collect_log(0, 0, None, error=diag)
return {"new_count": 0, "total_count": 0, "naver_ok": None, "diag": diag}
```
> `save_listing_collect_log`의 naver_ok 인자는 `1 if naver_ok else 0` 처리이므로 None→0 저장(무해). `_fetch_naver_all`/`_parse_naver_article`는 삭제하지 말 것(ingest 재사용).
> 기존 `test_collect_writes_molit_diag_to_log`(계측 테스트)가 `_fetch_naver_all`을 `lambda: (0,0)`로 mock하는데 이제 미호출이므로 여전히 통과(무해). `test_molit_call_returns_diag_on_http_error`·`_parse_*`도 무관.
- [ ] **Step 4: 통과 확인**`PYTHONPATH=... python -m pytest tests/test_listing_collector.py -q` → PASS (신규 + 기존 계측/파서 테스트)
- [ ] **Step 5: 커밋**
```bash
git add realestate-lab/app/listing_collector.py realestate-lab/tests/test_listing_collector.py
git commit -m "refactor(realestate): collect_listings MOLIT 전용화(네이버는 워커 ingest로 이전)"
```
---
### Task 6: 회귀 + 카탈로그/메모리 + 배포 + 계약 핸드오프
**Files:** `CLAUDE.md`, 메모리 `service_realestate.md`/`infra_distributed_workers.md`
- [ ] **Step 1: 전체 회귀**`cd realestate-lab && PYTHONPATH="C:/Users/jaeoh/Desktop/workspace/web-backend" python -m pytest -q` (0 fail) + `cd agent-office && python -m pytest -q` (0 fail)
- [ ] **Step 2: CLAUDE.md** — §5 nginx 표에 `/api/internal/realestate/`(IP화이트리스트) 추가, §9 realestate 표에 `GET /api/internal/realestate/targets`·`POST /listings-ingest` 추가
- [ ] **Step 3: 메모리**`service_realestate.md`(내부 계약·NAVER_CORTAR·collect MOLIT전용화), `infra_distributed_workers.md`(naver-fetch fetcher kind 등재, /api/internal/realestate/ 계약)
- [ ] **Step 4: 계약 잠금·핸드오프** — co-gahusb `acquire_lock("nginx-conf","BE")`+`acquire_lock("nas-deploy","BE")` → 커밋 push(자동배포). AI(web-ai)에 §4 계약(targets/ingest 스키마·heartbeat kind=fetcher·INTERNAL_API_KEY)·병렬 spike 요청, FE(web-ui)에 /infra fetcher 노드 요청 `post_message`. 배포 후 `GET /api/internal/realestate/targets`(X-Internal-Key) 200 검증 → 락 해제.
```bash
git add CLAUDE.md
git commit -m "docs(CLAUDE.md): naver 워커 internal 엔드포인트 + nginx 등재"
git push origin main
```
---
## Self-Review
**Spec coverage:** §4.1 targets→T2, §4.2 ingest→T3, §4.3 cortarNo→T1, §4.4 heartbeat(계약, 워커측)→T4 등재로 관측, §5 관측→T4, §6 인증(nginx+X-Internal-Key)→T2, §7 멱등/에러→T3(try/except·article_no UNIQUE), §8 테스트→각 Task, §3 _fetch_naver_all deprecate→T5, §10 핸드오프→T6. ✓
- 스펙 대비 조정: env명 `INTERNAL_API_KEY`(기존 관례, 스펙의 REALESTATE_INTERNAL_KEY 대체) — Global Constraints에 명시. matched=notify sent 수로 구체화(스펙 {received,new,matched} 유지).
**Placeholder scan:** 각 Step 실제 코드/명령. T4의 collect_status fetcher-link 상세 테스트만 "기존 test_node_monitor 픽스처 따라"로 위임(기존 파일 참조, 트리비얼 등재 테스트는 완전). ✓
**Type consistency:** `naver_cortar`(T1)→internal_router(T2) 사용 일치. `upsert_listing``(dict,is_new)`(기존)·`_parse_naver_article`(기존 반환 dict)·`notify_new_listings``{sent,...}`(기존)→ingest 소비 일치. targets 반환 `{dongs:[{dong,cortar_no}],deal_types,page_limit}`=스펙 §4.1. ingest 반환 `{received,new,matched}`=스펙 §4.2. WORKER_REGISTRY 항목 형식(T4)=기존 구조 일치. ✓
**범위:** BE 단일 세션(realestate T1-3,5 + agent-office T4 + 통합 T6). 워커(web-ai)·FE(web-ui)는 별도. 단일 실행 계획 적정(6 tasks).