Сайдкар МЕРЫ: зона браузера по IP узла, якорь Домклика без Яндекса, автологин Циана через пул, время навигации в логах #3563
2 changed files with 176 additions and 4 deletions
|
|
@ -145,6 +145,7 @@ import logging
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
from collections.abc import Callable, Mapping
|
from collections.abc import Callable, Mapping
|
||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
from urllib.parse import quote, urlparse
|
from urllib.parse import quote, urlparse
|
||||||
|
|
@ -837,6 +838,12 @@ _last_goto_at: dict[str, float] = {} # provider → loop-time последн
|
||||||
# _fetch_once (сбрасывается в None перед навигацией, чтобы не отдать чужой
|
# _fetch_once (сбрасывается в None перед навигацией, чтобы не отдать чужой
|
||||||
# протухший статус), читается fetch_handler'ом под тем же _locks[provider] — гонки нет.
|
# протухший статус), читается fetch_handler'ом под тем же _locks[provider] — гонки нет.
|
||||||
_last_response_status: dict[str, int | None] = {}
|
_last_response_status: dict[str, int | None] = {}
|
||||||
|
# #3419: provider → длительность ЦЕЛЕВОЙ page.goto последнего /fetch в мс (None — до неё не
|
||||||
|
# дошли: упал прогрев origin, fetch_mode не navigate). Пишется в _fetch_once и на успехе,
|
||||||
|
# и на исключении goto; читается fetch_handler'ом под тем же локом для строки «fetch error»,
|
||||||
|
# а «fetch OK» печатает её сам. Нужна, чтобы порог BROWSER_NAV_TIMEOUT_MS сравнивать с
|
||||||
|
# распределением времени навигации, а не подбирать вслепую.
|
||||||
|
_last_nav_ms: dict[str, int | None] = {}
|
||||||
# #2164 P4: proxy-url, с которым СЕЙЧАС запущен инстанс провайдера (env или динамический
|
# #2164 P4: proxy-url, с которым СЕЙЧАС запущен инстанс провайдера (env или динамический
|
||||||
# из пула, переданный в теле /fetch). Нужен для политики «relaunch ТОЛЬКО при реальной
|
# из пула, переданный в теле /fetch). Нужен для политики «relaunch ТОЛЬКО при реальной
|
||||||
# смене прокси» — camoufox берёт proxy на launch, релонч дорогой, поэтому не релончим,
|
# смене прокси» — camoufox берёт proxy на launch, релонч дорогой, поэтому не релончим,
|
||||||
|
|
@ -1465,8 +1472,9 @@ async def fetch_handler(request: web.Request) -> web.Response:
|
||||||
status = _last_response_status.get(provider)
|
status = _last_response_status.get(provider)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
"tradein-browser[%s]: fetch error url=%r: %s: %s",
|
"tradein-browser[%s]: fetch error nav_ms=%s url=%r: %s: %s",
|
||||||
provider,
|
provider,
|
||||||
|
_last_nav_ms.get(provider),
|
||||||
url,
|
url,
|
||||||
type(exc).__name__,
|
type(exc).__name__,
|
||||||
exc,
|
exc,
|
||||||
|
|
@ -2360,6 +2368,7 @@ async def _fetch_once(
|
||||||
# Гасим статус прошлой навигации ДО работы: если goto упадёт, наверх не должен
|
# Гасим статус прошлой навигации ДО работы: если goto упадёт, наверх не должен
|
||||||
# уехать статус предыдущей страницы этого же провайдера (#3196).
|
# уехать статус предыдущей страницы этого же провайдера (#3196).
|
||||||
_last_response_status[provider] = None
|
_last_response_status[provider] = None
|
||||||
|
_last_nav_ms[provider] = None
|
||||||
|
|
||||||
if reset_context:
|
if reset_context:
|
||||||
await _close_reusable_context(provider)
|
await _close_reusable_context(provider)
|
||||||
|
|
@ -2434,7 +2443,11 @@ async def _fetch_once(
|
||||||
}
|
}
|
||||||
if referer:
|
if referer:
|
||||||
goto_kwargs["referer"] = referer
|
goto_kwargs["referer"] = referer
|
||||||
response = await page.goto(url, **goto_kwargs) # type: ignore[attr-defined]
|
nav_started = time.monotonic()
|
||||||
|
try:
|
||||||
|
response = await page.goto(url, **goto_kwargs) # type: ignore[attr-defined]
|
||||||
|
finally:
|
||||||
|
_last_nav_ms[provider] = int((time.monotonic() - nav_started) * 1000)
|
||||||
_last_response_status[provider] = _status_of(response)
|
_last_response_status[provider] = _status_of(response)
|
||||||
if BROWSER_WAIT_MS > 0:
|
if BROWSER_WAIT_MS > 0:
|
||||||
await page.wait_for_timeout(BROWSER_WAIT_MS) # type: ignore[attr-defined]
|
await page.wait_for_timeout(BROWSER_WAIT_MS) # type: ignore[attr-defined]
|
||||||
|
|
@ -2521,9 +2534,12 @@ async def _fetch_once(
|
||||||
await page.close() # type: ignore[attr-defined]
|
await page.close() # type: ignore[attr-defined]
|
||||||
|
|
||||||
_page_counters[provider] = _page_counters.get(provider, 0) + 1
|
_page_counters[provider] = _page_counters.get(provider, 0) + 1
|
||||||
logger.debug(
|
# INFO, а не DEBUG (#3419): без строк успеха распределение времени навигации не
|
||||||
"tradein-browser[%s]: fetch OK url=%r pages_since_launch=%d",
|
# снять, а сравнивать его с таймаутами и есть цель.
|
||||||
|
logger.info(
|
||||||
|
"tradein-browser[%s]: fetch OK nav_ms=%s url=%r pages_since_launch=%d",
|
||||||
provider,
|
provider,
|
||||||
|
_last_nav_ms.get(provider),
|
||||||
url,
|
url,
|
||||||
_page_counters[provider],
|
_page_counters[provider],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
156
tradein-mvp/browser/test_server_nav_timing.py
Normal file
156
tradein-mvp/browser/test_server_nav_timing.py
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
"""test_server_nav_timing.py — длительность целевой навигации в логах /fetch (#3419).
|
||||||
|
|
||||||
|
Порог BROWSER_NAV_TIMEOUT_MS=60000 не с чем было сравнить: «fetch OK» писался на DEBUG
|
||||||
|
и без длительности, «fetch error» — тоже без неё. Теперь обе строки несут nav_ms —
|
||||||
|
время ЦЕЛЕВОЙ page.goto (без прогрева origin, пейсинга и BROWSER_WAIT_MS).
|
||||||
|
|
||||||
|
Часы подменяются только в модуле сервера (server.time), event loop их не видит.
|
||||||
|
camoufox НЕ запускается: browser/page поддельные.
|
||||||
|
|
||||||
|
Запуск (из tradein-mvp/browser/)::
|
||||||
|
|
||||||
|
python -m pytest test_server_nav_timing.py -q
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import importlib.util
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from aiohttp.test_utils import make_mocked_request
|
||||||
|
|
||||||
|
_SERVER_PATH = Path(__file__).resolve().parent / "server.py"
|
||||||
|
_spec = importlib.util.spec_from_file_location("tradein_browser_server_nav_timing", _SERVER_PATH)
|
||||||
|
assert _spec is not None and _spec.loader is not None
|
||||||
|
server = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(server)
|
||||||
|
|
||||||
|
_ORIGIN = "https://www.cian.ru/"
|
||||||
|
_CARD = "https://ekb.cian.ru/sale/flat/1/"
|
||||||
|
|
||||||
|
|
||||||
|
class _Clock:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.now = 1000.0
|
||||||
|
|
||||||
|
def monotonic(self) -> float:
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
|
||||||
|
class _Page:
|
||||||
|
"""goto двигает поддельные часы на заданное время; на origin или по флагу — падает."""
|
||||||
|
|
||||||
|
def __init__(self, clock: _Clock, nav_s: float, *, fail_target: bool, fail_origin: bool):
|
||||||
|
self._clock = clock
|
||||||
|
self._nav_s = nav_s
|
||||||
|
self._fail_target = fail_target
|
||||||
|
self._fail_origin = fail_origin
|
||||||
|
|
||||||
|
async def route(self, pattern: str, handler: Any) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def goto(self, url: str, **kwargs: Any) -> None:
|
||||||
|
if url == _ORIGIN:
|
||||||
|
if self._fail_origin:
|
||||||
|
raise TimeoutError("origin goto timeout")
|
||||||
|
return None
|
||||||
|
self._clock.now += self._nav_s
|
||||||
|
if self._fail_target:
|
||||||
|
raise TimeoutError("Page.goto: Timeout 60000ms exceeded.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def wait_for_timeout(self, ms: int) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def content(self) -> str:
|
||||||
|
return "<html><body>карточка</body></html>"
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class _Browser:
|
||||||
|
def __init__(self, page: _Page) -> None:
|
||||||
|
self._page = page
|
||||||
|
|
||||||
|
async def new_page(self) -> _Page:
|
||||||
|
return self._page
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> _Clock:
|
||||||
|
for name in ("_browsers", "_page_counters", "_locks", "_last_goto_at",
|
||||||
|
"_last_response_status", "_last_nav_ms", "_launched_proxy"):
|
||||||
|
monkeypatch.setattr(server, name, {})
|
||||||
|
monkeypatch.setattr(server, "_locks_guard", asyncio.Lock())
|
||||||
|
monkeypatch.setattr(server, "IS_PROD", False)
|
||||||
|
monkeypatch.setattr(server, "BROWSER_WAIT_MS", 0)
|
||||||
|
monkeypatch.setattr(server, "_MIN_PAGE_INTERVAL_BY_PROVIDER", {})
|
||||||
|
monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 0.0)
|
||||||
|
monkeypatch.setattr(server, "_RECYCLE_PAGES_BY_PROVIDER", dict.fromkeys(server.PROVIDERS, 10_000))
|
||||||
|
|
||||||
|
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(server, "_ensure_browser", _ensure)
|
||||||
|
clock = _Clock()
|
||||||
|
monkeypatch.setattr(server, "time", SimpleNamespace(monotonic=clock.monotonic))
|
||||||
|
return clock
|
||||||
|
|
||||||
|
|
||||||
|
async def _coro(value: Any) -> Any:
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch(page: _Page, body: dict[str, Any]) -> int:
|
||||||
|
server._browsers["cian"] = _Browser(page)
|
||||||
|
request = make_mocked_request("POST", "/fetch")
|
||||||
|
request.json = lambda: _coro(body) # type: ignore[method-assign]
|
||||||
|
return asyncio.run(server.fetch_handler(request)).status
|
||||||
|
|
||||||
|
|
||||||
|
def _nav_ms(caplog: pytest.LogCaptureFixture, marker: str) -> list[int | None]:
|
||||||
|
"""nav_ms из строк лога с маркером, по порядку: число или None."""
|
||||||
|
values: list[int | None] = []
|
||||||
|
for record in caplog.records:
|
||||||
|
message = record.getMessage()
|
||||||
|
if marker in message:
|
||||||
|
match = re.search(r"nav_ms=(\d+|None)\b", message)
|
||||||
|
assert match is not None, f"в строке нет nav_ms: {message}"
|
||||||
|
values.append(None if match.group(1) == "None" else int(match.group(1)))
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def test_success_logs_target_navigation_ms_at_info(
|
||||||
|
_reset_state: _Clock, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
page = _Page(_reset_state, 12.345, fail_target=False, fail_origin=False)
|
||||||
|
with caplog.at_level(logging.INFO, logger=server.logger.name):
|
||||||
|
status = _fetch(page, {"url": _CARD, "origin": _ORIGIN})
|
||||||
|
|
||||||
|
assert status == 200
|
||||||
|
assert _nav_ms(caplog, "[cian]: fetch OK") == [12345]
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeout_logs_navigation_ms_then_prenav_failure_logs_none(
|
||||||
|
_reset_state: _Clock, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""Таймаут цели несёт своё время; следующий отказ ДО цели не наследует прошлое число."""
|
||||||
|
with caplog.at_level(logging.INFO, logger=server.logger.name):
|
||||||
|
first = _fetch(
|
||||||
|
_Page(_reset_state, 60.0007, fail_target=True, fail_origin=False),
|
||||||
|
{"url": _CARD, "origin": _ORIGIN},
|
||||||
|
)
|
||||||
|
second = _fetch(
|
||||||
|
_Page(_reset_state, 5.0, fail_target=False, fail_origin=True),
|
||||||
|
{"url": _CARD, "origin": _ORIGIN},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (first, second) == (500, 500)
|
||||||
|
assert _nav_ms(caplog, "[cian]: fetch error") == [60000, None]
|
||||||
Loading…
Add table
Reference in a new issue