Files
ai-trade/services/naver-fetch/DESIGN.md

153 lines
9.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# naver-fetch 워커 — 구현 설계
> **2026-07-10 · web-ai 소유.** realestate 매물 알림 파이프라인의 가정-IP fetch 워커.
> **권위 계약(원본 스펙)**: web-backend repo `realestate-lab/docs/superpowers/specs/2026-07-09-naver-listing-worker-design.md` §4.
> 이 문서는 그 계약을 Windows Docker 워커로 구현하기 위한 **구현 설계**.
---
## 1. 역할 · 경계
네이버가 NAS(데이터센터) IP를 429/ReadTimeout으로 막기 때문에, **가정 IP(LAN 192.168.45.x)에서 네이버 매물을 fetch해 NAS로 전달**하는 얇은 프록시 워커. trade-monitor·render 워커와 동일한 FastAPI + asyncio 관례. WSL2 Docker.
**책임**
1. 2~3h 주간 주기로 NAS `targets` 조회(감시 동 목록).
2. Playwright로 네이버 Bearer 토큰 획득 → httpx로 동별 매물 fetch(페이지네이션).
3. raw article을 그대로 NAS `listings-ingest`로 전달(**파싱·매칭·알림은 NAS**).
4. Redis heartbeat(`worker:naver-fetch:heartbeat` EX45, kind=fetcher).
5. 토큰/네트워크 오류를 사이클·동 단위로 격리.
**경계 밖(안 함)**: article 파싱, 매물 매칭, 텔레그램 발송(전부 NAS), dedup.
### Spike 검증 완료 (2026-07-10)
- Playwright 헤드리스 → `new.land.naver.com` 로드 → `/api/articles` XHR의 **Bearer JWT 토큰 인터셉트 성공**.
- 인터셉트 토큰+쿠키를 httpx로 재사용 → `GET /api/articles?cortarNo=1159010100&...` **HTTP 200, articleList 20건**(isMoreData 페이지네이션). 토큰 있으면 이 LAN에서 429 회피됨.
- raw article 키: `articleNo, articleName, tradeTypeName, dealOrWarrantPrc, areaName, buildingName, floorInfo` 등 → NAS로 그대로 전달.
---
## 2. 모듈 분해
| 파일 | 책임 | 인터페이스 |
|------|------|-----------|
| `main.py` | FastAPI + lifespan. `fetch_loop` + `heartbeat_loop` 스폰, `/health` | 부작용 |
| `fetch_worker.py` | `FetchState`, `run_cycle`, `fetch_loop`, `heartbeat_loop`(전용) | 부작용 |
| `nas_client.py` | `get_targets()` / `post_ingest(fetched_at, batches)` (X-Internal-Key + retry) | 부작용(HTTP) |
| `naver_client.py` | `NaverClient`: `acquire_token()`(Playwright) + `fetch_articles(cortar_no, page)`(httpx) | 부작용(브라우저/HTTP) |
| `config.py` | `Settings` — env 로드 | 순수 |
순수 로직이 적은 워커(얇은 프록시)라 파싱은 없음. 테스트는 HTTP mock(respx) + fetch_worker 조립 중심.
---
## 3. 데이터 흐름 (run_cycle)
```
state.state = "fetching"
await naver.acquire_token() # Playwright 1회 (사이클당)
tg = await nas.get_targets() # {dongs:[{dong,cortar_no}], deal_types, page_limit}
for d in tg.dongs: # 동 단위 try/except 격리 (fetch/ingest 각각)
articles = []
for page in 1..tg.page_limit:
resp = await naver.fetch_articles(d.cortar_no, page) # httpx GET
articles += resp.get("articleList", [])
if not resp.get("isMoreData"): break
# ⚠️ 동별로 즉시 ingest — 전체 batch를 한 POST로 보내면 NAS 동기 처리(매물 수 비례)가
# >180s 타임아웃. 동 단위(각 수십건)로 쪼개 각 POST가 timeout 내 완료 + 부분 실패 격리.
r = await nas.post_ingest(fetched_at=iso_utc_now, batches=[{"dong": d.dong, "articles": articles}])
state.listings_pushed += len(articles) # {received,new,matched}
state.last_fetch_at = epoch_now
state.state = "idle"
```
> ⚠️ **ingest 분할**: 스펙은 batches(복수)를 한 POST로 예시하지만, 첫 실행 대량(6동×~40건)이 NAS에서 >180s 걸려 타임아웃 → **동별 1 POST**로 분할(batches 리스트 계약 호환, 각 POST는 batches 1개). nas_client의 ingest는 timeout 180s·재시도 없음(재전송 시 NAS 중복 알림 방지). BE에 분할 통지 필요.
> ⚠️ **시간 포맷 2종**: ingest `fetched_at`은 **ISO UTC 문자열**(스펙 §4 ingest 스키마, 예 `2026-07-09T05:44:23Z`), heartbeat `ts`/`last_fetch_at`은 **epoch 정수**(스펙 §4.4). 혼동 주의.
`heartbeat_loop`(별도 15초)이 `FetchState`를 읽어 §4.4 페이로드 발신.
---
## 4. naver_client (Playwright 토큰 + httpx fetch)
- `async acquire_token()`: `async_playwright`로 chromium 헤드리스 기동 → `new.land.naver.com/houses?ms=...`(설정 `NAVER_TOKEN_URL`) 로드 → `page.on("request")`로 첫 `Authorization: Bearer ...` 헤더 캡처 → `self._token`, `self._cookies`(context.cookies) 저장 → 브라우저 종료. 실패 시 예외(사이클 skip).
- **토큰 수명 정책**: 사이클마다 fresh 획득(2~3h당 1회). JWT 만료 무관, 캐시/갱신 로직 없음(단순).
- `async fetch_articles(cortar_no, page) -> dict`: httpx GET `https://new.land.naver.com/api/articles?cortarNo=<cortar_no>&order=rank&realEstateType=<NAVER_REAL_ESTATE_TYPE>&tradeType=&...&page=<page>` with headers `{authorization: self._token, user-agent, referer: https://new.land.naver.com/, accept, cookie: self._cookies}`. 200 아니면 예외. raw JSON dict 반환.
- `async close()`: httpx client 정리.
**얇은 프록시**: tradeType 비움(전체 유형) + realEstateType 설정값(기본 `APT:ABYG:JGC:PRE`). deal_types 필터는 NAS 담당(targets의 deal_types는 워커가 그대로 두고 NAS가 매칭 시 사용). 추후 네이버 쿼리 최적화 여지.
---
## 5. heartbeat (§4.4 — 전용 스키마)
`worker:naver-fetch:heartbeat` EX45. **trade-monitor의 `_shared.build_payload`(ISO ts + jobs_done/jobs_failed)와 스키마가 달라 전용 emit 작성**:
```json
{"name":"naver-fetch","kind":"fetcher","state":"idle|fetching",
"ts":<epoch int>,"last_fetch_at":<epoch int|null>,"listings_pushed":<int>,"errors":<int>}
```
- 15초 독립 태스크(주기 fetch가 2~3h라 TTL45 유지 위해 필수). `FetchState`를 읽어 발신.
- BE가 node_monitor.WORKER_REGISTRY에 `naver-fetch`(kind=fetcher)로 등재 완료 → 발신 시 `/nodes`·`/infra`에서 alive.
---
## 6. 스케줄 (fetch_loop)
- env `FETCH_INTERVAL_HOURS`(기본 2.5), `DAYTIME_START`/`DAYTIME_END`(기본 08:00/20:00 KST).
- 루프: ~5분마다 판정 — **주간창 안 && (now - last_run) ≥ interval** 이면 `run_cycle` 실행 후 `last_run=now`. 창 밖이면 idle(state=idle, fetch 안 함).
- 최상위 try/except로 루프 불사(어떤 예외도 죽이지 않음).
---
## 7. 설정 (env)
| env | 기본값 | 용도 |
|-----|--------|------|
| `NAS_BASE_URL` (compose: `NAVER_NAS_BASE_URL`) | `http://192.168.45.54:18800` | **realestate-lab 직결** targets/ingest (stock :18500 아님) |
| `INTERNAL_API_KEY` | (필수) | `X-Internal-Key` (render 워커 공유키) |
| `REDIS_URL` | `redis://192.168.45.54:6379` | heartbeat |
| `FETCH_INTERVAL_HOURS` | `2.5` | fetch 주기 |
| `DAYTIME_START` / `DAYTIME_END` | `08:00` / `20:00` | 주간창(KST) |
| `NAVER_REAL_ESTATE_TYPE` | `APT:ABYG:JGC:PRE` | 네이버 realEstateType |
| `NAVER_TOKEN_URL` | (기본 상수) | Playwright 토큰 획득용 네이버 URL |
| `NAVER_HEADLESS` | `1` | Playwright headless |
---
## 8. 에러 처리
- **토큰 획득 실패**: 사이클 skip, `errors++`, state=idle, 다음 주기 재시도.
- **targets 실패**: 사이클 skip, `errors++`.
- **동별 fetch 실패**: 해당 동 skip(warning), 나머지 동 계속.
- **ingest 실패**: 로그 error, `errors++`(다음 주기 재fetch).
- 루프 최상위 try/except.
---
## 9. 테스트 (pytest, 시스템 Python)
| 파일 | 검증 |
|------|------|
| `test_config.py` | env 기본/override |
| `test_nas_client.py` | respx — targets 파싱, ingest 페이로드/응답, X-Internal-Key, retry |
| `test_naver_client.py` | respx — fetch_articles URL·헤더(authorization/cookie)·articleList 파싱·비200 예외. (Playwright acquire_token은 실브라우저 통합이라 단위 mock: 토큰 주입 후 fetch 경로만) |
| `test_fetch_worker.py` | mock: 주간창 게이트(창 밖 skip), run_cycle 동별 조립·batches, 동 fetch 실패 격리, listings_pushed/last_fetch_at 갱신, heartbeat 필드(epoch ts/kind=fetcher) |
---
## 10. 배포
- `Dockerfile`: Playwright 베이스(`mcr.microsoft.com/playwright/python` 또는 insta-render 관례) + chromium, `COPY _shared /app/_shared`(빌드 컨텍스트 `.`), `COPY naver-fetch/. /app/`, `PYTHONPATH=/app`, uvicorn `:8000`.
- `services/docker-compose.yml`: `naver-fetch` 서비스, 포트 **18716**, `TZ=Asia/Seoul`, INTERNAL_API_KEY/REDIS/NAS env, healthcheck `/health`.
- 엔드포인트는 nginx `/api/internal/realestate/`(IP 화이트리스트, render 워커 IP 재사용).
---
## 11. 미해결 플래그 / 후속
1. **`INTERNAL_API_KEY` vs `REALESTATE_INTERNAL_KEY`** — 스펙 §config는 후자, BE 메시지는 전자(render 공유키). **BE 메시지 우선(INTERNAL_API_KEY)**, 배포 전 co-gahusb로 1줄 확인.
2. **네이버 헤드리스 탐지** — 현재 spike는 통과. 차단 시 stealth 헤더/headed 필요할 수 있음(첫 운영 모니터링).
3. **`isMoreData`/페이지 상한** — page_limit로 제한, 네이버 rate limit 재유입 시 동 간 지연 고려(첫 운영).
4. **deal_types 미사용** — 워커는 전체 유형 fetch, NAS가 필터. 필요 시 tradeType 매핑으로 최적화.
5. **토큰 재사용 실패(만료) 대비** — 사이클 중 fetch가 401 반환 시 acquire_token 1회 재시도(구현 시 반영 검토).