diff --git a/services/naver-fetch/naver_client.py b/services/naver-fetch/naver_client.py new file mode 100644 index 0000000..b3694dd --- /dev/null +++ b/services/naver-fetch/naver_client.py @@ -0,0 +1,79 @@ +"""Naver land client — Playwright 토큰 획득 + httpx article fetch.""" +from __future__ import annotations + +import logging + +import httpx + +logger = logging.getLogger(__name__) + +_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36") +_API = "https://new.land.naver.com/api/articles" + + +class NaverClient: + def __init__(self, real_estate_type: str, token_url: str, + headless: bool = True, timeout: float = 20.0): + self._ret = real_estate_type + self._token_url = token_url + self._headless = headless + self._client = httpx.AsyncClient(timeout=timeout) + self._token: str | None = None + self._cookies: str = "" + + async def close(self) -> None: + await self._client.aclose() + + async def acquire_token(self) -> None: + """new.land.naver.com 로드 → XHR Authorization 헤더 인터셉트.""" + from playwright.async_api import async_playwright + + captured: dict[str, str] = {} + async with async_playwright() as p: + browser = await p.chromium.launch(headless=self._headless) + try: + ctx = await browser.new_context(user_agent=_UA, locale="ko-KR") + page = await ctx.new_page() + + def on_request(req): + auth = req.headers.get("authorization") + if auth and "bearer" in auth.lower() and "auth" not in captured: + captured["auth"] = auth + + page.on("request", on_request) + try: + await page.goto(self._token_url, wait_until="networkidle", timeout=45000) + except Exception: + logger.warning("naver token goto 지연/부분 실패 — 캡처 계속") + await page.wait_for_timeout(4000) + cookies = await ctx.cookies() + self._cookies = "; ".join(f"{c['name']}={c['value']}" for c in cookies) + finally: + await browser.close() + + token = captured.get("auth") + if not token: + raise RuntimeError("naver Bearer token 획득 실패") + self._token = token + logger.info("naver token 획득 (len=%d)", len(token)) + + async def fetch_articles(self, cortar_no: str, page: int = 1) -> dict: + if not self._token: + raise RuntimeError("token 없음 — acquire_token 먼저 호출") + params = { + "cortarNo": cortar_no, "order": "rank", "realEstateType": self._ret, + "tradeType": "", "rentPriceMin": 0, "rentPriceMax": 900000000, + "priceMin": 0, "priceMax": 900000000, "areaMin": 0, "areaMax": 900000000, + "showArticle": "false", "sameAddressGroup": "false", + "priceType": "RETAIL", "page": page, + } + headers = { + "authorization": self._token, "user-agent": _UA, + "referer": "https://new.land.naver.com/", + "accept": "application/json, text/plain, */*", + "accept-language": "ko-KR,ko;q=0.9", "cookie": self._cookies, + } + resp = await self._client.get(_API, params=params, headers=headers) + resp.raise_for_status() + return resp.json() diff --git a/services/naver-fetch/tests/test_naver_client.py b/services/naver-fetch/tests/test_naver_client.py new file mode 100644 index 0000000..87eade4 --- /dev/null +++ b/services/naver-fetch/tests/test_naver_client.py @@ -0,0 +1,55 @@ +"""NaverClient.fetch_articles — httpx 경로 (respx). acquire_token은 실브라우저라 제외.""" +import httpx +import pytest +import respx + +from naver_client import NaverClient + +API = "https://new.land.naver.com/api/articles" + + +def _client(): + return NaverClient(real_estate_type="APT:ABYG", token_url="https://x", headless=True) + + +async def test_fetch_requires_token(): + c = _client() + with pytest.raises(RuntimeError): + await c.fetch_articles("1159010100", 1) + await c.close() + + +@respx.mock +async def test_fetch_articles_request_and_parse(): + captured = {} + + def _resp(request): + captured["auth"] = request.headers.get("authorization") + captured["cookie"] = request.headers.get("cookie") + captured["url"] = str(request.url) + return httpx.Response(200, json={ + "isMoreData": True, + "articleList": [{"articleNo": "111", "articleName": "테스트아파트"}]}) + + respx.get(API).mock(side_effect=_resp) + c = _client() + c._token = "Bearer TESTJWT" # acquire_token 우회(단위테스트) + c._cookies = "NNB=abc; page_uid=xyz" + out = await c.fetch_articles("1159010100", page=2) + assert out["articleList"][0]["articleNo"] == "111" + assert captured["auth"] == "Bearer TESTJWT" + assert captured["cookie"] == "NNB=abc; page_uid=xyz" + assert "cortarNo=1159010100" in captured["url"] + assert "page=2" in captured["url"] + assert "realEstateType=APT" in captured["url"] + await c.close() + + +@respx.mock +async def test_fetch_non_200_raises(): + respx.get(API).mock(return_value=httpx.Response(429, json={"code": "TOO_MANY_REQUESTS"})) + c = _client() + c._token = "Bearer X" + with pytest.raises(httpx.HTTPStatusError): + await c.fetch_articles("1159010100", 1) + await c.close()