From b0155830f29a0cb4ec2128ed42a91d52e8374d2c Mon Sep 17 00:00:00 2001 From: gahusb Date: Fri, 10 Jul 2026 10:21:03 +0900 Subject: [PATCH] =?UTF-8?q?feat(naver-fetch):=20NAS=20realestate=20?= =?UTF-8?q?=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20(targets/ingest?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/naver-fetch/nas_client.py | 48 +++++++++++++++++++ services/naver-fetch/tests/test_nas_client.py | 41 ++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 services/naver-fetch/nas_client.py create mode 100644 services/naver-fetch/tests/test_nas_client.py diff --git a/services/naver-fetch/nas_client.py b/services/naver-fetch/nas_client.py new file mode 100644 index 0000000..deb535f --- /dev/null +++ b/services/naver-fetch/nas_client.py @@ -0,0 +1,48 @@ +"""NAS realestate 내부 계약 — X-Internal-Key + retry.""" +from __future__ import annotations + +import asyncio +import logging + +import httpx + +logger = logging.getLogger(__name__) + +_MAX_ATTEMPTS = 3 +_RETRY_STATUSES = {429, 500, 502, 503, 504} + + +class NASClient: + def __init__(self, base_url: str, api_key: str, timeout: float = 15.0): + self._base_url = base_url.rstrip("/") + self._api_key = api_key + self._client = httpx.AsyncClient(timeout=timeout) + + async def close(self) -> None: + await self._client.aclose() + + async def get_targets(self) -> dict: + return await self._request("GET", "/api/internal/realestate/targets") + + async def post_ingest(self, fetched_at: str, batches: list[dict]) -> dict: + return await self._request( + "POST", "/api/internal/realestate/listings-ingest", + json={"fetched_at": fetched_at, "batches": batches}) + + async def _request(self, method: str, path: str, **kwargs) -> dict: + url = f"{self._base_url}{path}" + headers = {"X-Internal-Key": self._api_key} + for attempt in range(_MAX_ATTEMPTS): + try: + resp = await self._client.request(method, url, headers=headers, **kwargs) + if resp.status_code in _RETRY_STATUSES and attempt < _MAX_ATTEMPTS - 1: + await asyncio.sleep(2 ** attempt) + continue + resp.raise_for_status() + return resp.json() + except httpx.TimeoutException: + if attempt < _MAX_ATTEMPTS - 1: + await asyncio.sleep(2 ** attempt) + continue + raise + raise RuntimeError("retry exhausted") diff --git a/services/naver-fetch/tests/test_nas_client.py b/services/naver-fetch/tests/test_nas_client.py new file mode 100644 index 0000000..d1e4d5a --- /dev/null +++ b/services/naver-fetch/tests/test_nas_client.py @@ -0,0 +1,41 @@ +"""NASClient — targets/ingest + X-Internal-Key (respx).""" +import json as _json + +import httpx +import respx + +from nas_client import NASClient + +BASE = "http://nas.test" + + +@respx.mock +async def test_get_targets_sends_key(): + route = respx.get(f"{BASE}/api/internal/realestate/targets").mock( + return_value=httpx.Response(200, json={ + "dongs": [{"dong": "신대방동", "cortar_no": "1159010100"}], + "deal_types": ["전세", "매매"], "page_limit": 2})) + c = NASClient(BASE, "KEY") + tg = await c.get_targets() + assert tg["page_limit"] == 2 + assert tg["dongs"][0]["cortar_no"] == "1159010100" + assert route.calls.last.request.headers["X-Internal-Key"] == "KEY" + await c.close() + + +@respx.mock +async def test_post_ingest_payload(): + captured = {} + + def _resp(request): + captured.update(_json.loads(request.content)) + return httpx.Response(200, json={"received": 3, "new": 2, "matched": 1}) + + respx.post(f"{BASE}/api/internal/realestate/listings-ingest").mock(side_effect=_resp) + c = NASClient(BASE, "KEY") + batches = [{"dong": "신대방동", "articles": [{"articleNo": "1"}, {"articleNo": "2"}]}] + out = await c.post_ingest("2026-07-10T05:00:00Z", batches) + assert out == {"received": 3, "new": 2, "matched": 1} + assert captured["fetched_at"] == "2026-07-10T05:00:00Z" + assert captured["batches"] == batches + await c.close()