diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25c2dfe..13a232f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,17 @@ on: branches: [main] jobs: + browser: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -e ".[dev,browser]" + - run: python -m playwright install --with-deps chromium + - run: python -m pytest tests/test_browser_playwright.py -q + typecheck: runs-on: ubuntu-latest steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 46900f5..8704838 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 2026-10-24 — 무작위 조작 대기와 HTML 결과 대기 단축 + +- 기본 클릭·선택 대기를 0.25~1.25초에서 0.05~0.15초로 줄이고 매 조작의 랜덤성을 유지한다. +- 시군구 선택이 이미 보낸 정상 조회 POST의 지역·탭·검색 모드·필터를 검증해 결과를 + 재사용하고 중복 버튼 조회를 줄인다. 지역 AJAX 목록과 실제 옵션의 일치를 확인한다. +- image/font/media만 제외하고 HTML 문서 완료·선택 지역·활성 탭 검증을 유지해 + load handler의 동적 추가 행도 보존한다. HTTP 캐시 비활성화 비용은 실측으로 확인한다. +- 30분은 실제 전국 실행으로 검증할 목표이며 미검증 속도 향상률을 보장하지 않는다. + ## 2026-10-24 — 지역 체크포인트와 저장 재개 - 선택적인 영속 체크포인트로 지역 목록·완료 결과·요청 예산을 보존하고 미완료 지역부터 재개한다. diff --git a/README.md b/README.md index d3ef8b3..b5da4b9 100644 --- a/README.md +++ b/README.md @@ -85,11 +85,11 @@ playwright install chromium ``` 웹 화면 수집기는 공식 API의 대체물이 아닌 실험 기능입니다. 기본적으로 -시군구 단위로 조회해 요청 수를 줄이고, 화면 조작 사이에는 짧은 무작위 지연, +시군구 단위로 조회해 요청 수를 줄이고, 화면 조작 사이에는 0.05~0.15초 무작위 지연, 전체 수집 사이에는 기본 8시간 간격을 적용하며 최근 24시간 실행 횟수를 최대 3회로 제한합니다. 자동화 탐지 우회, 브라우저 지문 조작, CAPTCHA 우회는 수행하지 않으며 사이트가 접근을 -거부하면 오류로 중단합니다. 한 실행의 검색 요청 상한과 지역 AJAX 완료 상태도 +거부하면 오류로 중단합니다. 한 실행의 지역 조회 시도 상한과 지역 AJAX 완료 상태도 검사해 접근 거부나 부분 결과를 정상적인 빈 결과로 저장하지 않습니다. 운영 전 [오피넷 이용약관·저작권·공개 API 안내](https://www.opinet.co.kr/user/custapi/openApiInfo.do)와 허용된 수집·보관·재배포 범위를 확인해야 합니다. 자세한 사용법은 diff --git a/docs/browser-collection.md b/docs/browser-collection.md index bfdb302..324af69 100644 --- a/docs/browser-collection.md +++ b/docs/browser-collection.md @@ -1,5 +1,35 @@ # 지역별 웹 화면 수집기 +## 2026-10-24 수집 시간 단축 + +기본 화면 조작 간격은 매번 독립적으로 뽑는 **0.05~0.15초 무작위 대기**다. +기존 0.25~1.25초보다 평균 대기는 0.75초에서 0.10초로 줄지만 랜덤성은 유지한다. +지역 선택 AJAX 완료·선택값 일치·검색 응답 검증은 생략하지 않는다. +지역 선택은 헤더 수신뿐 아니라 응답 본문 완료까지 제한 시간 안에 기다린 뒤 +하위 목록과 선택값을 확인한다. 빈 결과도 선택 지역·활성 탭이 맞아야 확정한다. + +가격 파싱에 쓰지 않는 `image`·`font`·`media` 요청만 제외하고 문서·스크립트·스타일· +XHR·fetch는 유지한다. HTML은 문서 완료 뒤 읽어 `load` handler가 추가하는 결과도 +포함한다. 이미지의 `alt` 등 DOM 속성은 보존한다. 요청 라우팅에 따른 HTTP 캐시 비활성화 +비용도 있으므로 운영 속도는 실제 실행으로 확인한다. + +실제 공개 화면에서 시군구 선택이 이미 결과 문서 POST를 보낸다. 시군구 모드에서 해당 +선택이 낸 main-frame 문서 응답의 지역·빈 읍면동·검색 모드·탭·빈 상호/주소 필터가 모두 +일치할 때 이를 재사용해 조회 버튼의 중복 POST를 줄인다. 동일 지역 선택이나 필터 등 +조건이 맞지 않으면 기존 조회 버튼으로 진행한다. 코드에서 form을 직접 제출하지 않는다. +활성 탭은 부모 `li.sel`과 `BTN_DIV`·`LPG_YN`도 함께 확인한다. + +시도 선택은 공개 `sigunguGisAjax`의 `result` 배열과 `SIGUNGU_NM`을 읽고 화면 옵션의 +값·이름·순서를 정확히 대조한다. 정상 빈 목록도 본문과 대조하므로 고정 대기 길이에 +의존해 이전 목록을 읽지 않는다. 시군구 선택의 문서 이동은 새 문서 완료를 확인한다. +기존 전국 문서 조회량 약1150회에서 탐색·중복 버튼 조회를 줄인 약460회는 요청량 모델이며, +실제 시간 단축률은 아니다. 자동 조회의 시간은 `selection` 누적 시간에 포함된다. + +전국 약230개 시군구·460회 검색의 30분 목표는 시작·탐색 약120초, 랜덤 대기 약100초를 +제외한 지역 선택·응답·파싱 평균이 약3.4초 이하여야 한다. 이는 시간 예산이며 운영 실측 +보장이 아니다. `selection`·`response`·`results` 누적 로그로 실제 병목을 확인한다. +실제 전국 수집을 30분 안에 완료하기 전에는 목표 달성으로 보고하지 않는다. + ## 2026-10-24 재개와 수집 비용 `OpinetBrowserCollector(checkpoint_path="/persistent/opinet.sqlite3")`는 지역별 정상 결과를 @@ -108,7 +138,7 @@ timeout 원인의 해결을 뜻하지는 않으며, 실제 수집 복구는 별 ## 반복 수집과 부하 분산 -기본 `OpinetBrowserThrottle`은 화면 조작 사이에 0.25~1.25초의 무작위 대기를 +기본 `OpinetBrowserThrottle`은 화면 조작 사이에 0.05~0.15초의 무작위 대기를 넣고, 전체 수집 사이에는 8시간의 대기를 선택한다. 최근 24시간 실행 횟수는 최대 3회로 제한한다. 이 지연은 사람인 것처럼 보이기 위한 기능이 아니라 요청과 화면 갱신을 한 시점에 몰리지 @@ -145,6 +175,8 @@ asyncio.run(main()) 8시간을 기다린다. 최근 24시간에 3회 실행한 경우에는 다음 실행 가능 시각까지 추가로 기다린다. `stop_event.set()` 또는 `asyncio` 작업 취소로 중단할 수 있다. 한 실행은 `max_search_requests`를 넘는 지역 조회를 시작하지 않는다. +이 예산의 단위는 탭별 지역 조회 시도다. 재시도는 별도1회를 차감하며, 선택 AJAX나 +필터 때문에 재사용하지 못한 자동 선택 POST까지 포함한 전체 HTTP 요청 수 상한은 아니다. 저장 실패나 일시적 네트워크 오류 후에도 다음 주기를 예약하려면 `on_error`에 호출 애플리케이션의 알림 함수를 전달한다. 기본값은 오류를 호출자에게 전파해 실패를 숨기지 않는다. `max_runs_per_24h`는 하나의 `run_forever()` 작업에 diff --git a/docs/implementation-status.md b/docs/implementation-status.md index 5db6cee..2ed2225 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -1,5 +1,10 @@ # python-opinet-api Implementation Status +2026-10-24 추가: 기본 무작위 조작 대기를 0.05~0.15초로 단축하고 시군구 선택의 +정상 자동 조회 응답을 검증해 중복 버튼 조회를 줄인다. 시도 응답 목록과 실제 옵션을 +대조하며 가격 파싱에 쓰지 않는 시각 자원을 제외한다. 문서 완료·지역·탭 검증을 유지해 +동적 추가 행을 빠뜨리지 않는다. 운영 전국30분 목표는 실제 실행으로 별도 확인한다. + 2026-10-24: 지역별 영속 체크포인트·완료 결과 재전달·단일 작성자 잠금·시군구 탐색 최적화를 추가했다. [복구 계약](browser-collection.md)을 따른다. 실제 운영 속도 개선률은 미측정이며 단계별 누적 시간 로그로 지역 선택·응답·결과 준비 비용을 구분한다. diff --git a/src/opinet/experimental/browser.py b/src/opinet/experimental/browser.py index 3dd6d66..50e532b 100644 --- a/src/opinet/experimental/browser.py +++ b/src/opinet/experimental/browser.py @@ -22,7 +22,7 @@ from pathlib import Path from typing import Any, Literal, TypeAlias from typing import Iterator -from urllib.parse import urlsplit +from urllib.parse import parse_qs, urlsplit from zoneinfo import ZoneInfo from .._convert import strip_or_none, to_bool_yn, to_float_or_none @@ -119,6 +119,17 @@ _REGION_LABEL_MAX_CHARS = 12 _REGION_SEARCH_MAX_ATTEMPTS = 2 _TAB_SELECTORS: dict[BrowserStationKind, str] = {"station": "#OS_BTN", "lpg": "#LPG_BTN"} +_TAB_READY_JS = """({selector, kind}) => { + const node = document.querySelector(selector); + if (!node) return false; + const active = node.getAttribute('aria-selected') === 'true' || + ['on', 'active', 'selected', 'sel'].some(name => node.classList.contains(name)) || + node.closest('li')?.classList.contains('sel'); + const mode = document.querySelector('#BTN_DIV'); + const fuel = document.querySelector('#LPG_YN'); + return Boolean(active) && (!mode || mode.value === (kind === 'lpg' ? 'lpg_btn' : 'os_btn')) && + (!fuel || (kind === 'lpg' ? fuel.value === 'Y' : fuel.value !== 'Y')); +}""" class _BrowserActionTimeoutError(OpinetServerError): @@ -214,10 +225,10 @@ async def _reject_blocked_response(response: Any, *, page: Any = None, timeout_m if callable(header_value): content_type = (await header_value("content-type") or "").lower() if "html" not in content_type: - return + return None text_method = getattr(response, "text", None) if not callable(text_method): - return + return None try: body = await text_method() except Exception as exc: @@ -253,6 +264,44 @@ async def _reject_blocked_response(response: Any, *, page: Any = None, timeout_m raise OpinetServerError("Opinet browser response appears to be an access-block page") +async def _route_collection_resource(route: Any) -> None: + """가격 파싱에 쓰지 않는 시각 자원만 제외하고 스크립트·데이터는 로드한다.""" + if route.request.resource_type in {"image", "font", "media"}: + await route.abort("blockedbyclient") + else: + await route.continue_() + + +def _sigungu_options(payload: Any) -> list[list[str]]: + """공개 sigunguGisAjax·sigunguGisResult0의 응답/옵션 계약을 적용한다.""" + rows = payload.get("result") if isinstance(payload, Mapping) else None + if not isinstance(rows, list): + raise OpinetServerError("Opinet sigungu response must contain a result array") + result = [] + for row in rows: + name = strip_or_none(row.get("SIGUNGU_NM")) if isinstance(row, Mapping) else None + if name is None: + raise OpinetServerError("Opinet sigungu response contains an invalid name") + result.append([name, name]) + return result + + +def _automatic_sigungu_result(page: Any, response: Any, region: BrowserRegion, kind: BrowserStationKind) -> bool: + """정상 지역 선택 UI가 이미 보낸 동일 검색의 문서 응답만 재사용한다.""" + if region.dong_value or response is None or not _is_post_response(response, _PAGE_ENDPOINT): + return False + request = response.request + navigation = getattr(request, "is_navigation_request", None) + if not callable(navigation) or not navigation() or request.frame != page.main_frame: + return False + fields = parse_qs(request.post_data or "", keep_blank_values=True) + expected = {"SIDO_NM0": region.sido_value, "SIDO_NM": region.sido_value, + "SIGUNGU_NM0": region.sigungu_value, "SIGUNGU_NM": region.sigungu_value, + "DONG_NM": "", "SEARCH_MOD": "addr", "OS_NM": "", "OS_ADDR": "", + "BTN_DIV": "lpg_btn" if kind == "lpg" else "os_btn", "LPG_YN": "Y" if kind == "lpg" else ""} + return all(fields.get(name) == [value] for name, value in expected.items()) + + @contextmanager def _observe_browser_action( page: Any, @@ -587,8 +636,8 @@ class OpinetBrowserSnapshot: class OpinetBrowserThrottle: """화면 조작과 전체 실행 사이의 부하 분산 정책.""" - action_min_seconds: float = 0.25 - action_max_seconds: float = 1.25 + action_min_seconds: float = 0.05 + action_max_seconds: float = 0.15 run_interval_min: timedelta = timedelta(hours=8) run_interval_max: timedelta = timedelta(hours=8) max_runs_per_24h: int = 3 @@ -939,6 +988,7 @@ async def _collect_browser(self, checkpoint: BrowserCheckpoint | None) -> Opinet try: browser = await playwright.chromium.launch(**launch_options) context = await browser.new_context() + await context.route("**/*", _route_collection_resource) page = await context.new_page() await self._open_search_page(page) await self._pause(page) @@ -1080,11 +1130,7 @@ async def _collect_page(self, page: Any, checkpoint: BrowserCheckpoint | None) - async def _activate_tab(self, page: Any, *, station_kind: BrowserStationKind, selector: str) -> None: locator = page.locator(selector) - class_name = (await locator.get_attribute("class") or "").lower() - aria_selected = await locator.get_attribute("aria-selected") - is_active = aria_selected == "true" or bool( - {"on", "active", "selected"}.intersection(class_name.split()) - ) + is_active = await page.evaluate(_TAB_READY_JS, arg={"selector": selector, "kind": station_kind}) if not is_active: with _observe_browser_action(page, f"tab:{station_kind}"): async with page.expect_response( @@ -1104,10 +1150,25 @@ async def _wait_for_page_ready(self, page: Any) -> None: timeout=self.timeout_ms, ) - async def _wait_for_results_dom(self, page: Any) -> None: + async def _wait_for_results_dom( + self, page: Any, *, + previous_document: float | None = None, region: BrowserRegion | None = None, + station_kind: BrowserStationKind = "station", + ) -> None: + # load handler가 추가하는 행까지 기다리고 빈 결과에도 지역·탭을 확인한다. await page.wait_for_function( - "() => document.readyState === 'complete' && " - "document.querySelector('table.tbl_type10') !== null", + "({previous, values, tab, kind}) => { " + "if (previous !== null && performance.timeOrigin === previous) return false; " + "if (document.readyState !== 'complete') return false; " + "if (!document.querySelector('table.tbl_type10')) return false; " + "if (!values.every(([selector, value]) => document.querySelector(selector)?.value === value)) return false; " + "const matchesTab = " + _TAB_READY_JS + "; " + "if (tab && !matchesTab({selector: tab, kind})) return false; " + "return true; }", + arg={"previous": previous_document, + "values": [["#SIDO_NM0", region.sido_value], ["#SIGUNGU_NM0", region.sigungu_value], + ["#DONG_NM", region.dong_value or ""]] if region is not None else [], + "tab": _TAB_SELECTORS[station_kind] if region is not None else None, "kind": station_kind}, timeout=self.timeout_ms, ) @@ -1194,17 +1255,20 @@ async def _read_options(self, page: Any, selector: str) -> tuple[tuple[str, str] result.append((value, name)) return tuple(result) - async def _select_value(self, page: Any, selector: str, value: str, *, parent: str | None = None) -> None: + async def _select_value(self, page: Any, selector: str, value: str, *, parent: str | None = None) -> Any: locator = page.locator(selector) current = await locator.input_value() if current == value: return region_endpoints = _REGION_ENDPOINTS_BY_SELECTOR.get(selector) dependent_selector = _DEPENDENT_SELECTOR_BY_SELECTOR.get(selector) + response = None + expected = None + origin = await page.evaluate("() => performance.timeOrigin") if dependent_selector is not None else None + navigation = False if region_endpoints is None: await locator.select_option(value=value) else: - # 숫자 지역 코드 또는 정제·절단한 지역 이름만 진단에 포함한다. 임의 문자열·URL은 기록하지 않는다. target = _region_label(value) if parent is None else f"{_region_label(parent)}/{_region_label(value)}" with _observe_browser_action(page, f"select:{selector}:target={target}"): async with page.expect_response( @@ -1218,18 +1282,27 @@ async def _select_value(self, page: Any, selector: str, value: str, *, parent: s raise OpinetServerError("unexpected Opinet region response URL") _validate_response(response, response_endpoint) await _reject_blocked_response(response, page=page, timeout_ms=self.timeout_ms) + await asyncio.wait_for(response.body(), timeout=self.timeout_ms / 1000) + if response_endpoint == "sigunguGisSelect.do": + expected = _sigungu_options(await response.json()) + is_navigation = getattr(response.request, "is_navigation_request", None) + navigation = callable(is_navigation) and is_navigation() await self._pause(page) if dependent_selector is not None: await page.wait_for_function( - "({selector, value, dependent}) => { " - "const current = document.querySelector(selector); " - "const next = document.querySelector(dependent); " - "return current !== null && current.value === value && " - "next !== null && (dependent === '#DONG_NM' || " - "Array.from(next.options).some(option => option.value.trim() !== '')); }", - arg={"selector": selector, "value": value, "dependent": dependent_selector}, + "({selector, value, dependent, expected, origin, navigation}) => { " + "const current = document.querySelector(selector); const next = document.querySelector(dependent); " + "if (!current || current.value !== value || !next) return false; " + "if (navigation && (performance.timeOrigin === origin || document.readyState !== 'complete')) return false; " + "if (Array.isArray(expected)) { const actual = Array.from(next.options) " + ".filter(o => o.value.trim() !== '').map(o => [o.value.trim(), o.textContent.trim()]); " + "return JSON.stringify(actual) === JSON.stringify(expected); } " + "return dependent === '#DONG_NM' || Array.from(next.options).some(o => o.value.trim() !== ''); }", + arg={"selector": selector, "value": value, "dependent": dependent_selector, "expected": expected, + "origin": origin, "navigation": navigation}, timeout=self.timeout_ms, ) + return response async def _discover_regions(self, page: Any) -> list[BrowserRegion]: regions: list[BrowserRegion] = [] @@ -1409,7 +1482,8 @@ async def _search_region( ) -> tuple[BrowserStation, ...]: selection_started = time.monotonic() await self._select_value(page, "#SIDO_NM0", region.sido_value) - await self._select_value(page, "#SIGUNGU_NM0", region.sigungu_value, parent=region.sido_value) + previous_document = await page.evaluate("() => performance.timeOrigin") + selection_response = await self._select_value(page, "#SIGUNGU_NM0", region.sigungu_value, parent=region.sido_value) await self._select_value(page, "#DONG_NM", region.dong_value or "") for selector, expected in ( ("#SIDO_NM0", region.sido_value), @@ -1427,14 +1501,18 @@ async def _search_region( self._phase_seconds["selection"] += response_started - selection_started phase = f"search:{station_kind}:region={_region_path_label(region)}:attempt={attempt}" - with _observe_browser_action(page, phase, timeout_error=_SearchResponseTimeoutError): - async with page.expect_response( - lambda response: _is_post_response(response, _SEARCH_ENDPOINT) - or _is_post_response(response, _PAGE_ENDPOINT), - timeout=self.timeout_ms, - ) as response_info: - await page.get_by_role("link", name="조회", exact=True).click(timeout=self.timeout_ms) - response = await response_info.value + if self.query_level == "sigungu" and _automatic_sigungu_result(page, selection_response, region, station_kind): + response = selection_response + else: + previous_document = await page.evaluate("() => performance.timeOrigin") + with _observe_browser_action(page, phase, timeout_error=_SearchResponseTimeoutError): + async with page.expect_response( + lambda response: _is_post_response(response, _SEARCH_ENDPOINT) + or _is_post_response(response, _PAGE_ENDPOINT), + timeout=self.timeout_ms, + ) as response_info: + await page.get_by_role("link", name="조회", exact=True).click(timeout=self.timeout_ms) + response = await response_info.value results_started = time.monotonic() self._phase_seconds["response"] += results_started - response_started response_endpoint = _SEARCH_ENDPOINT if _has_endpoint(str(response.url), _SEARCH_ENDPOINT) else _PAGE_ENDPOINT @@ -1452,7 +1530,11 @@ async def _search_region( query_level=self.query_level, ) else: - await self._wait_for_results_dom(page) + is_navigation = getattr(response.request, "is_navigation_request", None) + await self._wait_for_results_dom( + page, region=region, station_kind=station_kind, + previous_document=previous_document if callable(is_navigation) and is_navigation() else None, + ) found = await self._read_dom_stations(page, region=region, station_kind=station_kind) self._phase_seconds["results"] += time.monotonic() - results_started return found diff --git a/tests/test_browser_playwright.py b/tests/test_browser_playwright.py index d07946b..68979bd 100644 --- a/tests/test_browser_playwright.py +++ b/tests/test_browser_playwright.py @@ -1,9 +1,14 @@ """외부 통신 없이 실제 Chromium의 응답 대기 실패를 재현한다.""" +import asyncio +import html +import json +from urllib.parse import parse_qs + import pytest from opinet.exceptions import OpinetServerError -from opinet.experimental.browser import OpinetBrowserCollector, OpinetBrowserThrottle +from opinet.experimental.browser import BrowserRegion, OpinetBrowserCollector, OpinetBrowserThrottle, _OS_POP_FIELDS, _route_collection_resource @pytest.mark.asyncio @@ -37,3 +42,161 @@ async def respond(route): assert requests == ["GET", "GET"] finally: await browser.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("block_visual", [False, True]) +@pytest.mark.parametrize("mode", ["server", "dynamic", "mixed", "empty", "wrong-region", "wrong-tab", "empty-wrong-region", "empty-wrong-tab"]) +async def test_html_results_wait_for_data_instead_of_unrelated_image(mode, block_visual): + api = pytest.importorskip("playwright.async_api", reason="browser extra가 필요한 검증") + region = BrowserRegion(sido_value="11", sido_name="서울", sigungu_value="110", sigungu_name="테스트구") + row = {"UNI_ID": "A0000001", "OS_NM": "테스트", "B027_P": "1800", "GIS_X_COOR": "321217", "GIS_Y_COOR": "525868"} + href = "javascript:fn_osPop(" + ",".join("'" + row.get(name, "") + "'" for name in _OS_POP_FIELDS) + ");" + link = '테스트' + + def document(*, result=False): + wrong_region = result and mode.endswith("wrong-region") + controls = ('' + '' + '' + '') + body = link if result and mode != "dynamic" and not mode.startswith("empty") else "" + extra_link = link.replace("A0000001", "A0000002") + dynamic = ('') if result and mode in ("dynamic", "mixed") else "" + return (controls + '
조회
' + '' + body + '
' + dynamic + + ('' if result else "")) + + release_image = asyncio.Event() + image_requested = asyncio.Event() + async with api.async_playwright() as playwright: + browser = await playwright.chromium.launch() + try: + context = await browser.new_context() + if block_visual: + await context.route("**/*", _route_collection_resource) + page = await context.new_page() + + async def respond(route): + if route.request.url.endswith("/slow-image.png"): + image_requested.set() + if block_visual: + await route.fallback() + return + await release_image.wait() + await route.fulfill(content_type="image/png", body=b"") + else: + await route.fulfill(content_type="text/html; charset=utf-8", body=document(result=route.request.method == "POST")) + + await page.route("**/*", respond) + await page.goto("https://www.opinet.co.kr/searRgSelect.do") + collector = OpinetBrowserCollector(timeout_ms=1500) + task = asyncio.create_task(collector._search_region(page, region, station_kind="station")) + try: + await asyncio.wait_for(image_requested.wait(), 3) + except TimeoutError: + if task.done(): + task.result() + raise + if not block_visual: + await asyncio.sleep(0.1) + assert not task.done() + release_image.set() + if "wrong" in mode: + with pytest.raises(Exception, match="Timeout"): + await task + else: + stations = await asyncio.wait_for(task, 2) + expected = 0 if mode == "empty" else (2 if mode == "mixed" else 1) + assert len(stations) == expected + assert await page.evaluate("document.readyState") == "complete" + assert release_image.is_set() is not block_visual + finally: + release_image.set() + await browser.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["late", "replace", "clear-late", "empty", "empty-noop"]) +async def test_region_selection_waits_for_actual_child_options(mode): + api = pytest.importorskip("playwright.async_api", reason="browser extra가 필요한 검증") + empty = mode.startswith("empty") + parent = "SIDO_NM0" + child = "SIGUNGU_NM0" + endpoint = "sigunguGisSelect.do" + options = '' if empty else '' + apply_options = "" if mode == "empty-noop" else ( + "document.getElementById(child).outerHTML = '';" + if mode == "replace" else "document.getElementById(child).innerHTML = options;" + ) + clear = "document.getElementById(child).innerHTML = '';" if mode in ("clear-late", "empty-noop") else "" + source = ('') + async with api.async_playwright() as playwright: + browser = await playwright.chromium.launch() + try: + page = await browser.new_page() + async def respond(route): + if route.request.method == "POST": + await route.fulfill(content_type="application/json", body=json.dumps({"result": [] if empty else [{"SIGUNGU_NM": "220"}]})) + else: + await route.fulfill(content_type="text/html; charset=utf-8", body=source) + await page.route("**/*", respond) + await page.goto("https://www.opinet.co.kr/searRgSelect.do") + collector = OpinetBrowserCollector(timeout_ms=700, query_level="dong") + await collector._select_value(page, "#" + parent, "22") + assert await collector._read_options(page, "#" + child) == (() if empty else (("220", "220"),)) + finally: + await browser.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("kind,filtered,same_region", [("station", False, False), ("lpg", False, False), + ("station", True, False), ("station", False, True)]) +async def test_sigungu_auto_query_is_reused_only_for_the_same_unfiltered_request(kind, filtered, same_region): + api = pytest.importorskip("playwright.async_api", reason="browser extra가 필요한 검증") + region = BrowserRegion(sido_value="11", sido_name="서울", sigungu_value="110", sigungu_name="구") + row = {"UNI_ID": "A0000001", "OS_NM": "테스트", "B027_P": "1800", "GIS_X_COOR": "321217", "GIS_Y_COOR": "525868"} + href = "javascript:fn_osPop(" + ",".join("'" + row.get(name, "") + "'" for name in _OS_POP_FIELDS) + ");" + posts = [] + def document(fields): + hidden = {"SIDO_NM": "11", "SIGUNGU_NM": fields.get("SIGUNGU_NM0", "0"), "SEARCH_MOD": "addr", + "BTN_DIV": "lpg_btn" if kind == "lpg" else "os_btn", "LPG_YN": "Y" if kind == "lpg" else ""} + return ('
' + + ''.join('' for key, value in hidden.items()) + + '' + + '' + + '' + + '' + + '' + + '' + + '조회' + + '
가격
') + async with api.async_playwright() as playwright: + browser = await playwright.chromium.launch() + try: + page = await browser.new_page() + async def respond(route): + fields = {"SIGUNGU_NM0": "110" if same_region else "0", "OS_NM": "필터" if filtered else ""} + if route.request.method == "POST": + parsed = parse_qs(route.request.post_data or "", keep_blank_values=True) + posts.append(parsed) + fields = {key: values[0] for key, values in parsed.items()} + await route.fulfill(content_type="text/html; charset=utf-8", body=document(fields)) + await page.route("**/*", respond) + await page.goto("https://www.opinet.co.kr/searRgSelect.do") + stations = await OpinetBrowserCollector(timeout_ms=1500)._search_region(page, region, station_kind=kind) + assert [station.station_id for station in stations] == ["A0000001"] + assert len(posts) == (2 if filtered and not same_region else 1) + assert posts[-1]["OS_NM"] == [""] + assert posts[-1]["SIGUNGU_NM0"] == ["110"] + finally: + await browser.close() diff --git a/tests/test_experimental_browser.py b/tests/test_experimental_browser.py index a52021d..d7787da 100644 --- a/tests/test_experimental_browser.py +++ b/tests/test_experimental_browser.py @@ -2,6 +2,8 @@ from dataclasses import replace from datetime import datetime, timedelta import random +from unittest.mock import AsyncMock +from urllib.parse import urlencode from types import SimpleNamespace import pytest @@ -419,6 +421,69 @@ def test_browser_collector_rejects_non_opinet_url(): OpinetBrowserCollector(query_level="invalid") +@pytest.mark.asyncio +async def test_default_action_delays_remain_random_and_bounded(): + page = _FakeRegionPage() + collector = OpinetBrowserCollector(rng=random.Random(23)) + for _ in range(100): + await collector._pause(page) + assert len(page.waits) == 100 + assert min(page.waits) >= 50 + assert max(page.waits) <= 150 + assert len(set(page.waits)) > 20 + # 전국 약230개 시군구의 두 탭에서 조작 대기가 시간을 지배하지 않는다. + assert sum(page.waits) / len(page.waits) * 1150 < 180_000 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("kind", ["image", "font", "media", "document", "script", "stylesheet", "xhr", "fetch", "other"]) +async def test_browser_resource_policy_keeps_scripts_and_price_data(kind): + route = SimpleNamespace(request=SimpleNamespace(resource_type=kind), abort=AsyncMock(), continue_=AsyncMock()) + await browser_module._route_collection_resource(route) + if kind in {"image", "font", "media"}: + route.abort.assert_awaited_once_with("blockedbyclient") + route.continue_.assert_not_awaited() + else: + route.continue_.assert_awaited_once_with() + route.abort.assert_not_awaited() + + +@pytest.mark.parametrize("payload", [None, [], {}, {"result": {}}, {"result": [None]}, {"result": [{}]}, {"result": [{"SIGUNGU_NM": " "}]}]) +def test_sigungu_response_cannot_turn_invalid_payload_into_empty_options(payload): + with pytest.raises(OpinetServerError): + browser_module._sigungu_options(payload) + + +def test_sigungu_response_preserves_order_and_empty_result(): + assert browser_module._sigungu_options({"result": []}) == [] + assert browser_module._sigungu_options({"result": [{"SIGUNGU_NM": "중구"}, {"SIGUNGU_NM": " 종로구 "}]}) == [ + ["중구", "중구"], ["종로구", "종로구"]] + + +@pytest.mark.parametrize("changed", ["OS_NM", "OS_ADDR", "SIDO_NM0", "SIDO_NM", "SIGUNGU_NM0", "SIGUNGU_NM", + "DONG_NM", "SEARCH_MOD", "BTN_DIV", "LPG_YN", "iframe", "duplicates"]) +def test_auto_query_reuse_rejects_different_search_scope(region, changed): + region = replace(region, dong_value=None, dong_name=None) + frame = object() + page = SimpleNamespace(main_frame=frame) + fields = {"SIDO_NM0": region.sido_value, "SIDO_NM": region.sido_value, + "SIGUNGU_NM0": region.sigungu_value, "SIGUNGU_NM": region.sigungu_value, + "DONG_NM": "", "SEARCH_MOD": "addr", "OS_NM": "", "OS_ADDR": "", + "BTN_DIV": "os_btn", "LPG_YN": ""} + request = SimpleNamespace(method="POST", frame=frame, is_navigation_request=lambda: True, + post_data=urlencode(fields)) + response = SimpleNamespace(request=request, url="https://www.opinet.co.kr/searRgSelect.do") + assert browser_module._automatic_sigungu_result(page, response, region, "station") + if changed == "iframe": + request.frame = object() + elif changed == "duplicates": + request.post_data += "&SIDO_NM0=" + region.sido_value + else: + fields[changed] = "다름" + request.post_data = urlencode(fields) + assert not browser_module._automatic_sigungu_result(page, response, region, "station") + + def test_browser_loader_reports_optional_dependency(monkeypatch): def missing(_name): raise ModuleNotFoundError("playwright") @@ -547,6 +612,9 @@ def __init__(self): self.locators = {} self.waits = [] + async def evaluate(self, _script, **_kwargs): + return 1.0 + def locator(self, selector): if selector.endswith(" option"): base = selector.removesuffix(" option") @@ -574,7 +642,7 @@ async def wait_for_timeout(self, milliseconds): def expect_response(self, predicate, timeout): assert timeout > 0 candidates = ( - _FakeResponse("https://www.opinet.co.kr/common/sigunguGisSelect.do"), + _FakeResponse("https://www.opinet.co.kr/common/sigunguGisSelect.do", payload={"result": [{"SIGUNGU_NM": "11680"}]}), _FakeResponse("https://www.opinet.co.kr/common/geocodeUtmkSelect.do"), ) for response in candidates: @@ -619,6 +687,43 @@ def replace_region(region, **changes): return BrowserRegion(**values) +@pytest.mark.asyncio +@pytest.mark.parametrize("finish_body", [True, False]) +async def test_region_selection_waits_for_body_after_response_headers(finish_body): + headers_received = asyncio.Event() + body_received = asyncio.Event() + + class StreamingResponse(_FakeResponse): + async def body(self): + headers_received.set() + await body_received.wait() + return b"{}" + + async def json(self): + return {"result": []} + + class StreamingPage(_FakeRegionPage): + def expect_response(self, predicate, timeout): + response = StreamingResponse("https://www.opinet.co.kr/common/sigunguGisSelect.do") + assert predicate(response) + return _FakeResponseContext(response) + + async def wait_for_function(self, _script, **_kwargs): + assert body_received.is_set() + + collector = OpinetBrowserCollector(timeout_ms=100, + throttle=OpinetBrowserThrottle(action_min_seconds=0, action_max_seconds=0)) + task = asyncio.create_task(collector._select_value(StreamingPage(), "#SIDO_NM0", "11")) + await asyncio.wait_for(headers_received.wait(), 1) + assert not task.done() + if finish_body: + body_received.set() + await task + else: + with pytest.raises(TimeoutError): + await task + + class _FakeResponse: def __init__(self, url, payload=None, content_type="application/json", status=200, text_body=""): self.url = url @@ -628,6 +733,9 @@ def __init__(self, url, payload=None, content_type="application/json", status=20 self.text_body = text_body self.request = SimpleNamespace(method="POST") + async def body(self): + return self.text_body.encode() + async def header_value(self, _name): return self.content_type @@ -736,6 +844,9 @@ def __init__(self, response, records=()): self.selectors = {} self.waits = [] + async def evaluate(self, _script, **_kwargs): + return 1.0 + def locator(self, selector): if selector == "table.tbl_type10 tbody tr": return _FakeLocator(values=self.records) @@ -868,6 +979,10 @@ def __init__(self): def locator(self, selector): return self.tabs[selector] + async def evaluate(self, _script, *, arg): + attributes = self.tabs[arg["selector"]].attributes + return attributes.get("aria-selected") == "true" or attributes.get("class") == "on" + def expect_response(self, predicate, timeout): assert timeout > 0 response = _FakeResponse("https://www.opinet.co.kr/searRgSelect.do") @@ -1061,6 +1176,10 @@ async def wait_for_timeout(self, _milliseconds): pass class FakeContext: + async def route(self, pattern, handler): + assert pattern == "**/*" + assert handler is browser_module._route_collection_resource + async def new_page(self): return FakePage()