feat(naver-fetch): NaverClient (Playwright 토큰 + httpx fetch)
This commit is contained in:
79
services/naver-fetch/naver_client.py
Normal file
79
services/naver-fetch/naver_client.py
Normal file
@@ -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()
|
||||||
55
services/naver-fetch/tests/test_naver_client.py
Normal file
55
services/naver-fetch/tests/test_naver_client.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user