From 6b96fdaedc6fa1f7750c60c84a4d2749875311ab Mon Sep 17 00:00:00 2001 From: digitie <964189+digitie@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:47:30 +0900 Subject: [PATCH 1/4] perf(browser): shorten randomized pauses and verify rendered results before asset load --- .github/workflows/ci.yml | 11 ++++ CHANGELOG.md | 7 +++ README.md | 2 +- docs/browser-collection.md | 19 ++++++- docs/implementation-status.md | 5 ++ src/opinet/experimental/browser.py | 81 +++++++++++++++++++++++++++--- tests/test_browser_playwright.py | 74 ++++++++++++++++++++++++++- tests/test_experimental_browser.py | 17 +++++++ 8 files changed, 205 insertions(+), 11 deletions(-) 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..08fd5f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 2026-10-24 — 무작위 조작 대기와 HTML 결과 대기 단축 + +- 기본 클릭·선택 대기를 0.25~1.25초에서 0.05~0.15초로 줄이고 매 조작의 랜덤성을 유지한다. +- 서버 HTML에 완성된 일반 주유소 결과가 있으면 새 문서·지역·탭·모든 원본 링크 일치를 + 확인한 즉시 읽는다. 이미지·지도 완료 대기를 줄이며 빈/동적 결과는 기존 완료 검증을 유지한다. +- 30분은 실제 전국 실행으로 검증할 목표이며 미검증 속도 향상률을 보장하지 않는다. + ## 2026-10-24 — 지역 체크포인트와 저장 재개 - 선택적인 영속 체크포인트로 지역 목록·완료 결과·요청 예산을 보존하고 미완료 지역부터 재개한다. diff --git a/README.md b/README.md index d3ef8b3..89289b8 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ playwright install chromium ``` 웹 화면 수집기는 공식 API의 대체물이 아닌 실험 기능입니다. 기본적으로 -시군구 단위로 조회해 요청 수를 줄이고, 화면 조작 사이에는 짧은 무작위 지연, +시군구 단위로 조회해 요청 수를 줄이고, 화면 조작 사이에는 0.05~0.15초 무작위 지연, 전체 수집 사이에는 기본 8시간 간격을 적용하며 최근 24시간 실행 횟수를 최대 3회로 제한합니다. 자동화 탐지 우회, 브라우저 지문 조작, CAPTCHA 우회는 수행하지 않으며 사이트가 접근을 diff --git a/docs/browser-collection.md b/docs/browser-collection.md index bfdb302..ed8866d 100644 --- a/docs/browser-collection.md +++ b/docs/browser-collection.md @@ -1,5 +1,22 @@ # 지역별 웹 화면 수집기 +## 2026-10-24 수집 시간 단축 + +기본 화면 조작 간격은 매번 독립적으로 뽑는 **0.05~0.15초 무작위 대기**다. +기존 0.25~1.25초보다 평균 대기는 0.75초에서 0.10초로 줄지만 랜덤성은 유지한다. +지역 선택 AJAX 완료·선택값 일치·검색 응답 검증은 생략하지 않는다. + +HTML 검색 응답의 결과 표에 일반 주유소 원본 링크가 이미 있으면 새 문서·지역 선택값· +활성 탭·응답 원본 링크 전체와 DOM의 일치를 확인하고 이미지·지도 자원의 `load`를 +기다리지 않는다. 가격과 원시 필드는 기존 링크 파서로 동일하게 읽는다. 서버 결과 증거가 +없는 빈 표, 동적 결과, 불법 주유소 DOM 필드 경로, 문서 이동 없는 HTML 응답은 기존 +문서 완료 대기를 유지한다. 빈 표 존재만으로 수집 성공을 확정하지 않는다. + +전국 약230개 시군구·460회 검색의 30분 목표는 시작·탐색 약120초, 랜덤 대기 약100초를 +제외한 지역 선택·응답·파싱 평균이 약3.4초 이하여야 한다. 이는 시간 예산이며 운영 실측 +보장이 아니다. `selection`·`response`·`results` 누적 로그로 실제 병목을 확인한다. +실제 전국 수집을 30분 안에 완료하기 전에는 목표 달성으로 보고하지 않는다. + ## 2026-10-24 재개와 수집 비용 `OpinetBrowserCollector(checkpoint_path="/persistent/opinet.sqlite3")`는 지역별 정상 결과를 @@ -108,7 +125,7 @@ timeout 원인의 해결을 뜻하지는 않으며, 실제 수집 복구는 별 ## 반복 수집과 부하 분산 -기본 `OpinetBrowserThrottle`은 화면 조작 사이에 0.25~1.25초의 무작위 대기를 +기본 `OpinetBrowserThrottle`은 화면 조작 사이에 0.05~0.15초의 무작위 대기를 넣고, 전체 수집 사이에는 8시간의 대기를 선택한다. 최근 24시간 실행 횟수는 최대 3회로 제한한다. 이 지연은 사람인 것처럼 보이기 위한 기능이 아니라 요청과 화면 갱신을 한 시점에 몰리지 diff --git a/docs/implementation-status.md b/docs/implementation-status.md index 5db6cee..128ff79 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초로 단축하고 서버가 렌더링한 +일반 주유소 결과는 새 문서·선택 지역·활성 탭·원본 링크 일치로 준비 여부를 확인한다. +빈/동적 결과의 조기 성공은 막는다. 실제 Chromium의 지연 이미지·동적 결과·잘못된 +지역/탭 회귀를 CI에서 실행하며, 운영 전국 30분 목표는 실제 실행으로 별도 확인한다. + 2026-10-24: 지역별 영속 체크포인트·완료 결과 재전달·단일 작성자 잠금·시군구 탐색 최적화를 추가했다. [복구 계약](browser-collection.md)을 따른다. 실제 운영 속도 개선률은 미측정이며 단계별 누적 시간 로그로 지역 선택·응답·결과 준비 비용을 구분한다. diff --git a/src/opinet/experimental/browser.py b/src/opinet/experimental/browser.py index 3dd6d66..0c3214c 100644 --- a/src/opinet/experimental/browser.py +++ b/src/opinet/experimental/browser.py @@ -18,6 +18,7 @@ from contextlib import contextmanager from dataclasses import dataclass, field, replace from datetime import datetime, timedelta +from html.parser import HTMLParser from types import MappingProxyType from pathlib import Path from typing import Any, Literal, TypeAlias @@ -207,17 +208,18 @@ def _validate_response(response: Any, endpoint: str) -> None: raise OpinetServerError(f"Opinet browser response failed: HTTP {status}", status_code=status) -async def _reject_blocked_response(response: Any, *, page: Any = None, timeout_ms: int = 30_000) -> None: +async def _reject_blocked_response(response: Any, *, page: Any = None, timeout_ms: int = 30_000) -> str | None: """차단·CAPTCHA 페이지를 정상적인 빈 결과로 처리하지 않는다.""" content_type = "" header_value = getattr(response, "header_value", None) 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 + source_body = True try: body = await text_method() except Exception as exc: @@ -244,13 +246,43 @@ async def _reject_blocked_response(response: Any, *, page: Any = None, timeout_m raise OpinetServerError("unexpected Opinet browser navigation while reading response") from exc await page.wait_for_load_state("domcontentloaded", timeout=timeout_ms) body = await page.content() + source_body = False if str(page.url) != current_url: raise OpinetServerError("Opinet browser navigation changed while reading document") from exc + original_body = body body = body[:20_000].lower() if "the service is not available." in body: raise OpinetServerError("Opinet browser returned a service-unavailable page") if any(marker in body for marker in _BLOCKED_PAGE_MARKERS): raise OpinetServerError("Opinet browser response appears to be an access-block page") + return original_body if source_body else None + + +class _ServerResultLinks(HTMLParser): + """응답 HTML에 이미 완성된 일반 주유소 결과가 있는지 확인한다.""" + + def __init__(self) -> None: + super().__init__() + self.table_depth = 0 + self.links: set[str] = set() + self.has_dynamic_fields = False + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + values = dict(attrs) + if tag == "table": + if self.table_depth or "tbl_type10" in (values.get("class") or "").split(): + self.table_depth += 1 + if self.table_depth and tag == "a": + href = values.get("href") or "" + if href.startswith("javascript:fn_osPop("): + self.links.add(href) + elif href.startswith("javascript:fnVolatInfowindow("): + # 이 경로는 가격·라벨도 DOM에서 읽으므로 기존 load 경계를 유지한다. + self.has_dynamic_fields = True + + def handle_endtag(self, tag: str) -> None: + if tag == "table" and self.table_depth: + self.table_depth -= 1 @contextmanager @@ -587,8 +619,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 @@ -1104,7 +1136,35 @@ 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, *, response_body: str | None = None, + previous_document: float | None = None, region: BrowserRegion | None = None, + station_kind: BrowserStationKind = "station", + ) -> None: + rendered = _ServerResultLinks() + if response_body is not None: + rendered.feed(response_body) + if rendered.links and not rendered.has_dynamic_fields and previous_document is not None and region is not None: + # 새 문서·선택값·응답에 있던 모든 원본 필드의 일치를 확인한다. 이미지·지도 + # load가 늦어도 이미 완성된 결과를 읽는다. 빈/동적 결과는 아래 기존 대기를 쓴다. + await page.wait_for_function( + "({previous, values, links, tab}) => { " + "if (performance.timeOrigin === previous || document.readyState === 'loading') return false; " + "if (!values.every(([selector, value]) => document.querySelector(selector)?.value === value)) return false; " + "const active = document.querySelector(tab); " + "if (!active || !(active.getAttribute('aria-selected') === 'true' || " + "['on', 'active', 'selected'].some(name => active.classList.contains(name)))) return false; " + "const actual = new Set(Array.from(document.querySelectorAll('table.tbl_type10 a'), " + "a => a.getAttribute('href')).filter(href => href && " + "(href.startsWith('javascript:fn_osPop(') || href.startsWith('javascript:fnVolatInfowindow(')))); " + "return actual.size === links.length && links.every(link => actual.has(link)); }", + arg={"previous": previous_document, + "values": [["#SIDO_NM0", region.sido_value], ["#SIGUNGU_NM0", region.sigungu_value], + ["#DONG_NM", region.dong_value or ""]], + "links": sorted(rendered.links), "tab": _TAB_SELECTORS[station_kind]}, + timeout=self.timeout_ms, + ) + return await page.wait_for_function( "() => document.readyState === 'complete' && " "document.querySelector('table.tbl_type10') !== null", @@ -1427,6 +1487,7 @@ async def _search_region( self._phase_seconds["selection"] += response_started - selection_started phase = f"search:{station_kind}:region={_region_path_label(region)}:attempt={attempt}" + 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) @@ -1439,7 +1500,7 @@ async def _search_region( self._phase_seconds["response"] += results_started - response_started response_endpoint = _SEARCH_ENDPOINT if _has_endpoint(str(response.url), _SEARCH_ENDPOINT) else _PAGE_ENDPOINT _validate_response(response, response_endpoint) - await _reject_blocked_response(response, page=page, timeout_ms=self.timeout_ms) + response_body = await _reject_blocked_response(response, page=page, timeout_ms=self.timeout_ms) content_type = (await response.header_value("content-type") or "").lower() if response_endpoint == _SEARCH_ENDPOINT or "json" in content_type: payload = await response.json() @@ -1452,7 +1513,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, response_body=response_body, 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..1c18260 100644 --- a/tests/test_browser_playwright.py +++ b/tests/test_browser_playwright.py @@ -1,9 +1,13 @@ """외부 통신 없이 실제 Chromium의 응답 대기 실패를 재현한다.""" +import asyncio +import html +import json + 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 @pytest.mark.asyncio @@ -37,3 +41,71 @@ async def respond(route): assert requests == ["GET", "GET"] finally: await browser.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["server", "dynamic", "empty", "wrong-region", "wrong-tab"]) +async def test_html_results_wait_for_data_instead_of_unrelated_image(mode): + 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 == "wrong-region" + controls = ('' + '' + '' + '') + body = link if result and mode not in ("dynamic", "empty") else "" + dynamic = ('') if result and mode == "dynamic" 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: + page = await browser.new_page() + + async def respond(route): + if route.request.url.endswith("/slow-image.png"): + image_requested.set() + 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 mode == "server": + stations = await asyncio.wait_for(task, 1) + assert [station.station_id for station in stations] == ["A0000001"] + assert stations[0].price_by_product + assert await page.evaluate("document.readyState") == "interactive" + elif mode in ("wrong-region", "wrong-tab"): + with pytest.raises(Exception, match="Timeout"): + await task + else: + # 빈 표를 DCL 시점에 정상 결과로 확정하면 동적 결과를 잃는다. + await asyncio.sleep(0.1) + assert not task.done() + release_image.set() + stations = await asyncio.wait_for(task, 2) + assert len(stations) == (1 if mode == "dynamic" else 0) + finally: + release_image.set() + await browser.close() diff --git a/tests/test_experimental_browser.py b/tests/test_experimental_browser.py index a52021d..950952b 100644 --- a/tests/test_experimental_browser.py +++ b/tests/test_experimental_browser.py @@ -419,6 +419,20 @@ 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 + + def test_browser_loader_reports_optional_dependency(monkeypatch): def missing(_name): raise ModuleNotFoundError("playwright") @@ -736,6 +750,9 @@ def __init__(self, response, records=()): self.selectors = {} self.waits = [] + async def evaluate(self, _script): + return 1.0 + def locator(self, selector): if selector == "table.tbl_type10 tbody tr": return _FakeLocator(values=self.records) From b2af98e7bd276130fc3cb2e56ef38dd32f60c13a Mon Sep 17 00:00:00 2001 From: digitie <964189+digitie@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:58:33 +0900 Subject: [PATCH 2/4] fix(browser): await selection bodies and validate empty result context --- docs/browser-collection.md | 2 ++ src/opinet/experimental/browser.py | 47 +++++++++++++++--------------- tests/test_browser_playwright.py | 11 +++---- tests/test_experimental_browser.py | 36 +++++++++++++++++++++++ 4 files changed, 68 insertions(+), 28 deletions(-) diff --git a/docs/browser-collection.md b/docs/browser-collection.md index ed8866d..c5aeed9 100644 --- a/docs/browser-collection.md +++ b/docs/browser-collection.md @@ -5,6 +5,8 @@ 기본 화면 조작 간격은 매번 독립적으로 뽑는 **0.05~0.15초 무작위 대기**다. 기존 0.25~1.25초보다 평균 대기는 0.75초에서 0.10초로 줄지만 랜덤성은 유지한다. 지역 선택 AJAX 완료·선택값 일치·검색 응답 검증은 생략하지 않는다. +지역 선택은 헤더 수신뿐 아니라 응답 본문 완료까지 제한 시간 안에 기다린 뒤 +하위 목록과 선택값을 확인한다. 빈 결과도 선택 지역·활성 탭이 맞아야 확정한다. HTML 검색 응답의 결과 표에 일반 주유소 원본 링크가 이미 있으면 새 문서·지역 선택값· 활성 탭·응답 원본 링크 전체와 DOM의 일치를 확인하고 이미지·지도 자원의 `load`를 diff --git a/src/opinet/experimental/browser.py b/src/opinet/experimental/browser.py index 0c3214c..52943b3 100644 --- a/src/opinet/experimental/browser.py +++ b/src/opinet/experimental/browser.py @@ -1144,30 +1144,28 @@ async def _wait_for_results_dom( rendered = _ServerResultLinks() if response_body is not None: rendered.feed(response_body) - if rendered.links and not rendered.has_dynamic_fields and previous_document is not None and region is not None: - # 새 문서·선택값·응답에 있던 모든 원본 필드의 일치를 확인한다. 이미지·지도 - # load가 늦어도 이미 완성된 결과를 읽는다. 빈/동적 결과는 아래 기존 대기를 쓴다. - await page.wait_for_function( - "({previous, values, links, tab}) => { " - "if (performance.timeOrigin === previous || document.readyState === 'loading') return false; " - "if (!values.every(([selector, value]) => document.querySelector(selector)?.value === value)) return false; " - "const active = document.querySelector(tab); " - "if (!active || !(active.getAttribute('aria-selected') === 'true' || " - "['on', 'active', 'selected'].some(name => active.classList.contains(name)))) return false; " - "const actual = new Set(Array.from(document.querySelectorAll('table.tbl_type10 a'), " - "a => a.getAttribute('href')).filter(href => href && " - "(href.startsWith('javascript:fn_osPop(') || href.startsWith('javascript:fnVolatInfowindow(')))); " - "return actual.size === links.length && links.every(link => actual.has(link)); }", - arg={"previous": previous_document, - "values": [["#SIDO_NM0", region.sido_value], ["#SIGUNGU_NM0", region.sigungu_value], - ["#DONG_NM", region.dong_value or ""]], - "links": sorted(rendered.links), "tab": _TAB_SELECTORS[station_kind]}, - timeout=self.timeout_ms, - ) - return + fast = bool(rendered.links and not rendered.has_dynamic_fields + and previous_document is not None and region is not None) + # 새 문서·선택값·탭은 빈 결과에도 확인한다. 원본 링크 증거가 있는 경우만 + # 이미지·지도 load를 기다리지 않고 완성된 결과를 읽는다. await page.wait_for_function( - "() => document.readyState === 'complete' && " - "document.querySelector('table.tbl_type10') !== null", + "({previous, values, links, tab, fast}) => { " + "if (previous !== null && performance.timeOrigin === previous) return false; " + "if (fast ? document.readyState === 'loading' : 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; " + "if (tab) { const active = document.querySelector(tab); " + "if (!active || !(active.getAttribute('aria-selected') === 'true' || " + "['on', 'active', 'selected'].some(name => active.classList.contains(name)))) return false; } " + "if (!fast) return true; " + "const actual = new Set(Array.from(document.querySelectorAll('table.tbl_type10 a'), " + "a => a.getAttribute('href')).filter(href => href && " + "(href.startsWith('javascript:fn_osPop(') || href.startsWith('javascript:fnVolatInfowindow(')))); " + "return actual.size === links.length && links.every(link => actual.has(link)); }", + arg={"previous": previous_document, "fast": fast, + "values": [["#SIDO_NM0", region.sido_value], ["#SIGUNGU_NM0", region.sigungu_value], + ["#DONG_NM", region.dong_value or ""]] if region is not None else [], + "links": sorted(rendered.links), "tab": _TAB_SELECTORS[station_kind] if region is not None else None}, timeout=self.timeout_ms, ) @@ -1278,6 +1276,9 @@ 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) + # expect_response는 헤더 수신 시점이다. 짧은 랜덤 대기 전에 본문까지 + # 받아야 하위 목록 갱신이 느린 응답을 이전 옵션으로 오인하지 않는다. + await asyncio.wait_for(response.finished(), timeout=self.timeout_ms / 1000) await self._pause(page) if dependent_selector is not None: await page.wait_for_function( diff --git a/tests/test_browser_playwright.py b/tests/test_browser_playwright.py index 1c18260..b427447 100644 --- a/tests/test_browser_playwright.py +++ b/tests/test_browser_playwright.py @@ -44,7 +44,7 @@ async def respond(route): @pytest.mark.asyncio -@pytest.mark.parametrize("mode", ["server", "dynamic", "empty", "wrong-region", "wrong-tab"]) +@pytest.mark.parametrize("mode", ["server", "dynamic", "empty", "wrong-region", "wrong-tab", "empty-wrong-region", "empty-wrong-tab"]) async def test_html_results_wait_for_data_instead_of_unrelated_image(mode): api = pytest.importorskip("playwright.async_api", reason="browser extra가 필요한 검증") region = BrowserRegion(sido_value="11", sido_name="서울", sigungu_value="110", sigungu_name="테스트구") @@ -53,12 +53,12 @@ async def test_html_results_wait_for_data_instead_of_unrelated_image(mode): link = '테스트' def document(*, result=False): - wrong_region = result and mode == "wrong-region" + wrong_region = result and mode.endswith("wrong-region") controls = ('' '' '' - '') - body = link if result and mode not in ("dynamic", "empty") else "" + '') + body = link if result and mode != "dynamic" and not mode.startswith("empty") else "" dynamic = ('') if result and mode == "dynamic" else "" return (controls + '
Date: Sat, 24 Oct 2026 10:31:17 +0900 Subject: [PATCH 3/4] perf(browser): reuse verified automatic queries and retain complete DOM validation --- CHANGELOG.md | 6 +- docs/browser-collection.md | 21 +++- docs/implementation-status.md | 8 +- src/opinet/experimental/browser.py | 172 ++++++++++++++++------------- tests/test_browser_playwright.py | 124 ++++++++++++++++++--- tests/test_experimental_browser.py | 76 ++++++++++++- 6 files changed, 296 insertions(+), 111 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08fd5f1..8704838 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,10 @@ ## 2026-10-24 — 무작위 조작 대기와 HTML 결과 대기 단축 - 기본 클릭·선택 대기를 0.25~1.25초에서 0.05~0.15초로 줄이고 매 조작의 랜덤성을 유지한다. -- 서버 HTML에 완성된 일반 주유소 결과가 있으면 새 문서·지역·탭·모든 원본 링크 일치를 - 확인한 즉시 읽는다. 이미지·지도 완료 대기를 줄이며 빈/동적 결과는 기존 완료 검증을 유지한다. +- 시군구 선택이 이미 보낸 정상 조회 POST의 지역·탭·검색 모드·필터를 검증해 결과를 + 재사용하고 중복 버튼 조회를 줄인다. 지역 AJAX 목록과 실제 옵션의 일치를 확인한다. +- image/font/media만 제외하고 HTML 문서 완료·선택 지역·활성 탭 검증을 유지해 + load handler의 동적 추가 행도 보존한다. HTTP 캐시 비활성화 비용은 실측으로 확인한다. - 30분은 실제 전국 실행으로 검증할 목표이며 미검증 속도 향상률을 보장하지 않는다. ## 2026-10-24 — 지역 체크포인트와 저장 재개 diff --git a/docs/browser-collection.md b/docs/browser-collection.md index c5aeed9..645e623 100644 --- a/docs/browser-collection.md +++ b/docs/browser-collection.md @@ -8,11 +8,22 @@ 지역 선택은 헤더 수신뿐 아니라 응답 본문 완료까지 제한 시간 안에 기다린 뒤 하위 목록과 선택값을 확인한다. 빈 결과도 선택 지역·활성 탭이 맞아야 확정한다. -HTML 검색 응답의 결과 표에 일반 주유소 원본 링크가 이미 있으면 새 문서·지역 선택값· -활성 탭·응답 원본 링크 전체와 DOM의 일치를 확인하고 이미지·지도 자원의 `load`를 -기다리지 않는다. 가격과 원시 필드는 기존 링크 파서로 동일하게 읽는다. 서버 결과 증거가 -없는 빈 표, 동적 결과, 불법 주유소 DOM 필드 경로, 문서 이동 없는 HTML 응답은 기존 -문서 완료 대기를 유지한다. 빈 표 존재만으로 수집 성공을 확정하지 않는다. +가격 파싱에 쓰지 않는 `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초 이하여야 한다. 이는 시간 예산이며 운영 실측 diff --git a/docs/implementation-status.md b/docs/implementation-status.md index 128ff79..2ed2225 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -1,9 +1,9 @@ # python-opinet-api Implementation Status -2026-10-24 추가: 기본 무작위 조작 대기를 0.05~0.15초로 단축하고 서버가 렌더링한 -일반 주유소 결과는 새 문서·선택 지역·활성 탭·원본 링크 일치로 준비 여부를 확인한다. -빈/동적 결과의 조기 성공은 막는다. 실제 Chromium의 지연 이미지·동적 결과·잘못된 -지역/탭 회귀를 CI에서 실행하며, 운영 전국 30분 목표는 실제 실행으로 별도 확인한다. +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 52943b3..50e532b 100644 --- a/src/opinet/experimental/browser.py +++ b/src/opinet/experimental/browser.py @@ -18,12 +18,11 @@ from contextlib import contextmanager from dataclasses import dataclass, field, replace from datetime import datetime, timedelta -from html.parser import HTMLParser from types import MappingProxyType 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 @@ -120,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): @@ -208,7 +218,7 @@ def _validate_response(response: Any, endpoint: str) -> None: raise OpinetServerError(f"Opinet browser response failed: HTTP {status}", status_code=status) -async def _reject_blocked_response(response: Any, *, page: Any = None, timeout_ms: int = 30_000) -> str | None: +async def _reject_blocked_response(response: Any, *, page: Any = None, timeout_ms: int = 30_000) -> None: """차단·CAPTCHA 페이지를 정상적인 빈 결과로 처리하지 않는다.""" content_type = "" header_value = getattr(response, "header_value", None) @@ -219,7 +229,6 @@ async def _reject_blocked_response(response: Any, *, page: Any = None, timeout_m text_method = getattr(response, "text", None) if not callable(text_method): return None - source_body = True try: body = await text_method() except Exception as exc: @@ -246,43 +255,51 @@ async def _reject_blocked_response(response: Any, *, page: Any = None, timeout_m raise OpinetServerError("unexpected Opinet browser navigation while reading response") from exc await page.wait_for_load_state("domcontentloaded", timeout=timeout_ms) body = await page.content() - source_body = False if str(page.url) != current_url: raise OpinetServerError("Opinet browser navigation changed while reading document") from exc - original_body = body body = body[:20_000].lower() if "the service is not available." in body: raise OpinetServerError("Opinet browser returned a service-unavailable page") if any(marker in body for marker in _BLOCKED_PAGE_MARKERS): raise OpinetServerError("Opinet browser response appears to be an access-block page") - return original_body if source_body else None -class _ServerResultLinks(HTMLParser): - """응답 HTML에 이미 완성된 일반 주유소 결과가 있는지 확인한다.""" +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 __init__(self) -> None: - super().__init__() - self.table_depth = 0 - self.links: set[str] = set() - self.has_dynamic_fields = False - def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: - values = dict(attrs) - if tag == "table": - if self.table_depth or "tbl_type10" in (values.get("class") or "").split(): - self.table_depth += 1 - if self.table_depth and tag == "a": - href = values.get("href") or "" - if href.startswith("javascript:fn_osPop("): - self.links.add(href) - elif href.startswith("javascript:fnVolatInfowindow("): - # 이 경로는 가격·라벨도 DOM에서 읽으므로 기존 load 경계를 유지한다. - self.has_dynamic_fields = True +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 handle_endtag(self, tag: str) -> None: - if tag == "table" and self.table_depth: - self.table_depth -= 1 + +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 @@ -971,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) @@ -1112,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( @@ -1137,35 +1151,24 @@ async def _wait_for_page_ready(self, page: Any) -> None: ) async def _wait_for_results_dom( - self, page: Any, *, response_body: str | None = None, + self, page: Any, *, previous_document: float | None = None, region: BrowserRegion | None = None, station_kind: BrowserStationKind = "station", ) -> None: - rendered = _ServerResultLinks() - if response_body is not None: - rendered.feed(response_body) - fast = bool(rendered.links and not rendered.has_dynamic_fields - and previous_document is not None and region is not None) - # 새 문서·선택값·탭은 빈 결과에도 확인한다. 원본 링크 증거가 있는 경우만 - # 이미지·지도 load를 기다리지 않고 완성된 결과를 읽는다. + # load handler가 추가하는 행까지 기다리고 빈 결과에도 지역·탭을 확인한다. await page.wait_for_function( - "({previous, values, links, tab, fast}) => { " + "({previous, values, tab, kind}) => { " "if (previous !== null && performance.timeOrigin === previous) return false; " - "if (fast ? document.readyState === 'loading' : document.readyState !== 'complete') 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; " - "if (tab) { const active = document.querySelector(tab); " - "if (!active || !(active.getAttribute('aria-selected') === 'true' || " - "['on', 'active', 'selected'].some(name => active.classList.contains(name)))) return false; } " - "if (!fast) return true; " - "const actual = new Set(Array.from(document.querySelectorAll('table.tbl_type10 a'), " - "a => a.getAttribute('href')).filter(href => href && " - "(href.startsWith('javascript:fn_osPop(') || href.startsWith('javascript:fnVolatInfowindow(')))); " - "return actual.size === links.length && links.every(link => actual.has(link)); }", - arg={"previous": previous_document, "fast": fast, + "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 [], - "links": sorted(rendered.links), "tab": _TAB_SELECTORS[station_kind] if region is not None else None}, + "tab": _TAB_SELECTORS[station_kind] if region is not None else None, "kind": station_kind}, timeout=self.timeout_ms, ) @@ -1252,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( @@ -1276,21 +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) - # expect_response는 헤더 수신 시점이다. 짧은 랜덤 대기 전에 본문까지 - # 받아야 하위 목록 갱신이 느린 응답을 이전 옵션으로 오인하지 않는다. - await asyncio.wait_for(response.finished(), timeout=self.timeout_ms / 1000) + 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] = [] @@ -1470,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), @@ -1488,20 +1501,23 @@ async def _search_region( self._phase_seconds["selection"] += response_started - selection_started phase = f"search:{station_kind}:region={_region_path_label(region)}:attempt={attempt}" - 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 + 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 _validate_response(response, response_endpoint) - response_body = await _reject_blocked_response(response, page=page, timeout_ms=self.timeout_ms) + await _reject_blocked_response(response, page=page, timeout_ms=self.timeout_ms) content_type = (await response.header_value("content-type") or "").lower() if response_endpoint == _SEARCH_ENDPOINT or "json" in content_type: payload = await response.json() @@ -1516,7 +1532,7 @@ async def _search_region( else: is_navigation = getattr(response.request, "is_navigation_request", None) await self._wait_for_results_dom( - page, response_body=response_body, region=region, station_kind=station_kind, + 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) diff --git a/tests/test_browser_playwright.py b/tests/test_browser_playwright.py index b427447..68979bd 100644 --- a/tests/test_browser_playwright.py +++ b/tests/test_browser_playwright.py @@ -3,11 +3,12 @@ import asyncio import html import json +from urllib.parse import parse_qs import pytest from opinet.exceptions import OpinetServerError -from opinet.experimental.browser import BrowserRegion, OpinetBrowserCollector, OpinetBrowserThrottle, _OS_POP_FIELDS +from opinet.experimental.browser import BrowserRegion, OpinetBrowserCollector, OpinetBrowserThrottle, _OS_POP_FIELDS, _route_collection_resource @pytest.mark.asyncio @@ -44,8 +45,9 @@ async def respond(route): @pytest.mark.asyncio -@pytest.mark.parametrize("mode", ["server", "dynamic", "empty", "wrong-region", "wrong-tab", "empty-wrong-region", "empty-wrong-tab"]) -async def test_html_results_wait_for_data_instead_of_unrelated_image(mode): +@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"} @@ -59,8 +61,9 @@ def document(*, result=False): '' '') body = link if result and mode != "dynamic" and not mode.startswith("empty") else "" - dynamic = ('') if result and mode == "dynamic" else "" + extra_link = link.replace("A0000001", "A0000002") + dynamic = ('') if result and mode in ("dynamic", "mixed") else "" return (controls + '조회
' '' + body + '
' + dynamic @@ -71,11 +74,17 @@ def document(*, result=False): async with api.async_playwright() as playwright: browser = await playwright.chromium.launch() try: - page = await browser.new_page() + 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: @@ -91,22 +100,103 @@ async def respond(route): if task.done(): task.result() raise - if mode == "server": - stations = await asyncio.wait_for(task, 1) - assert [station.station_id for station in stations] == ["A0000001"] - assert stations[0].price_by_product - assert await page.evaluate("document.readyState") == "interactive" - elif "wrong" in mode: + 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: - # 빈 표를 DCL 시점에 정상 결과로 확정하면 동적 결과를 잃는다. - await asyncio.sleep(0.1) - assert not task.done() - release_image.set() stations = await asyncio.wait_for(task, 2) - assert len(stations) == (1 if mode == "dynamic" else 0) + 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 88eb7b6..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 @@ -433,6 +435,55 @@ async def test_default_action_delays_remain_random_and_bounded(): 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") @@ -561,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") @@ -588,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: @@ -640,9 +694,13 @@ async def test_region_selection_waits_for_body_after_response_headers(finish_bod body_received = asyncio.Event() class StreamingResponse(_FakeResponse): - async def finished(self): + 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): @@ -675,8 +733,8 @@ def __init__(self, url, payload=None, content_type="application/json", status=20 self.text_body = text_body self.request = SimpleNamespace(method="POST") - async def finished(self): - return None + async def body(self): + return self.text_body.encode() async def header_value(self, _name): return self.content_type @@ -786,7 +844,7 @@ def __init__(self, response, records=()): self.selectors = {} self.waits = [] - async def evaluate(self, _script): + async def evaluate(self, _script, **_kwargs): return 1.0 def locator(self, selector): @@ -921,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") @@ -1114,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() From c4222832fa58795b9fef173b0a2038073c05302f Mon Sep 17 00:00:00 2001 From: digitie <964189+digitie@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:38:06 +0900 Subject: [PATCH 4/4] =?UTF-8?q?docs(browser):=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=98=88=EC=82=B0=EC=9D=98=20=EC=8B=9C=EB=8F=84=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- docs/browser-collection.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 89289b8..b5da4b9 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ playwright install chromium 전체 수집 사이에는 기본 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 645e623..324af69 100644 --- a/docs/browser-collection.md +++ b/docs/browser-collection.md @@ -175,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()` 작업에