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 = '
' 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 = '| 가격 |