# 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=&order=rank&realEstateType=&tradeType=&...&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":,"last_fetch_at":,"listings_pushed":,"errors":} ``` - 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회 재시도(구현 시 반영 검토).