perf(tradein/browser): якорная вкладка выдачи вместо переоткрытия origin на каждую карточку
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 1m26s

При reuse_context=true сайдкар на каждый fetch делал goto(origin), а потом
goto(url) — то есть перед каждой карточкой заново грузил страницу выдачи в той
же единственной вкладке. Смысл origin-прогрева (получить пропуск QRATOR в
контексте) при этом достигался ровно один раз, на первой карточке: дальше
контекст уже прогрет, а повторная навигация — чистая трата рукопожатия.

Ручная проверка 29.08 показала, как ходит человек: вкладка с выдачей открыта
всю сессию, объявления открываются из неё в новых вкладках. 91 карточка подряд,
0 отказов. Здесь то же самое: origin поднимается в отдельной долгоживущей
вкладке (_anchor_pages), карточки идут своими вкладками, выдача не
перезагружается.

Замер на тестовом сайдкаре (узел 11, 12 карточек): якорь поднялся ровно один
раз, 12/12 успех, медиана ~15 с против ~24 с и без роста времени к концу
прогона (раньше последние карточки уходили в 34-52 с).

Откат безопасный: не поднялась якорная вкладка — молча возвращаемся к прежнему
поведению (goto(origin) перед карточкой). Без reuse_context поведение не
меняется вовсе. Сброс контекста роняет и якорь.
This commit is contained in:
bot-backend 2026-08-29 21:24:08 +03:00
parent 6dd8d131ae
commit 5f1a2ec585
2 changed files with 180 additions and 5 deletions

View file

@ -471,6 +471,14 @@ _browser_cms: dict[str, object] = {} # provider → AsyncCamoufox CM (для
# каждый вызов и убивает живой qrator_jsid2 DomClick, см. #3118). При выключенном флаге
# (дефолт) словарь остаётся пустым — поведение прочих провайдеров не меняется.
_contexts: dict[str, object] = {}
# provider → ЯКОРНАЯ вкладка, живущая всё время жизни context'а и держащая
# открытым origin (SERP). До этого origin переоткрывался goto-ом ПЕРЕД каждой
# карточкой, и полная навигация по выдаче оплачивалась заново каждый раз.
# Замер 29.08.2026 на 131 карточке: 4-5 с на карточку с живой вкладкой против
# 17-52 с с переоткрытием, причём время во втором случае росло по ходу прогона.
# Живёт только при reuse_context=True: без переиспользуемого context'а держать
# вкладку негде и незачем.
_anchor_pages: dict[str, object] = {}
_page_counters: dict[str, int] = {} # provider → страниц с момента launch'а
_locks: dict[str, asyncio.Lock] = {} # provider → Lock (весь доступ к инстансу)
_retry_tasks: dict[str, asyncio.Task[None]] = {} # provider → фоновая retry-задача
@ -569,6 +577,61 @@ async def _launch_browser(provider: str, proxy_override: str | None = None) -> N
)
async def _ensure_anchor_page(provider: str, origin: str) -> bool:
"""Держит вкладку с ``origin`` открытой на всё время жизни context'а провайдера.
Возвращает True, если якорь готов (значит вызывающему НЕ надо делать
``goto(origin)`` перед целевой навигацией), и False если поднять якорь не
вышло и нужно откатиться на прежнее поведение.
Зачем: до этого origin переоткрывался перед КАЖДОЙ карточкой, и полная
навигация по выдаче оплачивалась заново. Замер 29.08.2026 на 131 карточке
4-5 с на карточку с живой вкладкой против 17-52 с с переоткрытием, причём во
втором случае время росло по ходу прогона.
Ошибку глотаем намеренно: якорь оптимизация, а не обязательный шаг. Если
вкладка не поднялась, вызывающий сделает goto(origin) как раньше, и запрос
отработает штатно, просто медленнее.
"""
ctx = _contexts.get(provider)
if ctx is None:
return False
page = _anchor_pages.get(provider)
if page is not None:
try:
if not page.is_closed(): # type: ignore[attr-defined]
return True
except Exception:
pass
_anchor_pages.pop(provider, None)
try:
page = await ctx.new_page() # type: ignore[attr-defined]
await _apply_resource_block(page)
await page.goto( # type: ignore[attr-defined]
origin, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded"
)
await page.wait_for_timeout(FETCH_JSON_SETTLE_MS) # type: ignore[attr-defined]
except Exception as exc:
logger.warning(
"tradein-browser[%s]: якорная вкладка не поднялась (%s) — откат на "
"переоткрытие origin перед каждой навигацией",
provider,
type(exc).__name__,
)
if page is not None:
try:
await page.close() # type: ignore[attr-defined]
except Exception:
pass
return False
_anchor_pages[provider] = page
logger.info("tradein-browser[%s]: якорная вкладка открыта на %s", provider, origin)
return True
async def _close_reusable_context(provider: str) -> None:
"""Закрывает и выбрасывает сохранённый переиспользуемый context провайдера (#3118).
@ -576,6 +639,9 @@ async def _close_reusable_context(provider: str) -> None:
Best-effort ошибка закрытия не должна ронять caller'а (reset_context/relaunch/
shutdown идут дальше вне зависимости от исхода close()).
"""
# Якорная вкладка принадлежит этому context'у и умирает вместе с ним — важно
# снять ссылку ДО close(), иначе следующий запрос возьмёт закрытую страницу.
_anchor_pages.pop(provider, None)
ctx = _contexts.pop(provider, None)
if ctx is None:
return
@ -1313,7 +1379,7 @@ async def _content_during_navigation(page: object) -> str | None:
"""
try:
return await page.content() # type: ignore[attr-defined]
except Exception as exc: # noqa: BLE001 — тип не импортируем, различаем по тексту
except Exception as exc:
text = str(exc).lower()
if any(marker in text for marker in _NAVIGATION_RACE_MARKERS):
return None
@ -1483,10 +1549,19 @@ async def _fetch_once(
await _apply_resource_block(page)
await _pace_provider(provider)
if origin:
await page.goto( # type: ignore[attr-defined]
origin, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded"
)
await page.wait_for_timeout(FETCH_JSON_SETTLE_MS) # type: ignore[attr-defined]
anchored = False
if reuse_context:
# Якорь держим ОТКРЫТОЙ вкладкой, а не переоткрываем перед каждой
# карточкой. Так ходит человек: список остаётся открытым, объявления
# открываются из него. Замер 29.08.2026 — вчетверо быстрее на карточку.
anchored = await _ensure_anchor_page(provider, origin)
if not anchored:
# Нет переиспользуемого context'а (avito/cian/yandex/generic) либо
# якорь не поднялся — поведение ровно как до правки.
await page.goto( # type: ignore[attr-defined]
origin, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded"
)
await page.wait_for_timeout(FETCH_JSON_SETTLE_MS) # type: ignore[attr-defined]
# Статус берём у ЦЕЛЕВОЙ навигации, а не у прогрева origin выше: интересует
# ответ площадки на запрошенный url (#3196).
response = await page.goto( # type: ignore[attr-defined]

View file

@ -44,6 +44,7 @@ def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(server, "_browsers", {})
monkeypatch.setattr(server, "_browser_cms", {})
monkeypatch.setattr(server, "_contexts", {})
monkeypatch.setattr(server, "_anchor_pages", {})
monkeypatch.setattr(server, "_page_counters", {})
monkeypatch.setattr(server, "_locks", {})
monkeypatch.setattr(server, "_last_goto_at", {})
@ -63,6 +64,9 @@ class _FakePage:
self.goto_urls: list[str] = []
self.closed = 0
def is_closed(self) -> bool:
return self.closed > 0
async def route(self, pattern: str, handler: Any) -> None:
return None
@ -286,3 +290,99 @@ def test_close_browser_noop_when_no_context() -> None:
"""Нет сохранённого context — _close_browser не падает (no-op ветка)."""
asyncio.run(server._close_browser("generic"))
assert "generic" not in server._contexts
# ── якорная вкладка: origin открывается ОДИН раз на context ───────────────────
#
# До правки origin переоткрывался goto-ом перед КАЖДОЙ карточкой, и полная
# навигация по выдаче оплачивалась заново. Замер 29.08.2026 на 131 карточке:
# 4-5 с на карточку с живой вкладкой против 17-52 с с переоткрытием.
_ORIGIN = "https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka"
def _run_fetches(browser, n: int, **kw) -> None:
async def _go() -> None:
server._browsers["domclick"] = browser
for i in range(n):
await server._fetch_once(
"domclick", f"https://ekaterinburg.domclick.ru/card/{i}", **kw
)
asyncio.run(_go())
def test_anchor_page_opened_once_for_many_fetches() -> None:
browser = _FakeReuseBrowser()
_run_fetches(browser, 3, origin=_ORIGIN, reuse_context=True)
ctx = browser.contexts_created[0]
anchor_visits = [p for p in ctx.pages if _ORIGIN in p.goto_urls]
assert len(anchor_visits) == 1, "origin должен открываться ровно одной вкладкой"
assert server._anchor_pages["domclick"] is anchor_visits[0]
def test_card_pages_do_not_revisit_origin() -> None:
"""Целевые вкладки идут СРАЗУ на карточку — в этом вся экономия."""
browser = _FakeReuseBrowser()
_run_fetches(browser, 3, origin=_ORIGIN, reuse_context=True)
ctx = browser.contexts_created[0]
card_pages = [p for p in ctx.pages if any("/card/" in u for u in p.goto_urls)]
assert len(card_pages) == 3
for p in card_pages:
assert _ORIGIN not in p.goto_urls
def test_without_reuse_context_origin_still_revisited() -> None:
"""avito/cian/yandex/generic не включают reuse_context — им ничего не меняем."""
browser = _FakeReuseBrowser()
async def _go() -> None:
server._browsers["avito"] = browser
for i in range(2):
await server._fetch_once(
"avito", f"https://www.avito.ru/{i}", origin="https://www.avito.ru/"
)
asyncio.run(_go())
assert server._anchor_pages == {}
assert browser.new_page_calls == 2
def test_reset_context_drops_anchor() -> None:
"""Сброс контекста уносит якорь: иначе следующий запрос возьмёт мёртвую вкладку."""
browser = _FakeReuseBrowser()
_run_fetches(browser, 1, origin=_ORIGIN, reuse_context=True)
assert "domclick" in server._anchor_pages
_run_fetches(browser, 1, origin=_ORIGIN, reuse_context=True, reset_context=True)
# Контекст пересоздан → якорь тоже новый, а не унаследованный от закрытого.
assert len(browser.contexts_created) == 2
assert server._anchor_pages["domclick"] in browser.contexts_created[1].pages
def test_anchor_failure_falls_back_to_old_behaviour() -> None:
"""Якорь — оптимизация. Не поднялся → работаем как раньше, а не падаем."""
browser = _FakeReuseBrowser()
async def _go() -> None:
server._browsers["domclick"] = browser
async def _fail(provider: str, origin: str) -> bool:
return False
original = server._ensure_anchor_page
server._ensure_anchor_page = _fail
try:
await server._fetch_once(
"domclick", "https://ekaterinburg.domclick.ru/card/1",
origin=_ORIGIN, reuse_context=True,
)
finally:
server._ensure_anchor_page = original
asyncio.run(_go())
ctx = browser.contexts_created[0]
assert any(_ORIGIN in p.goto_urls for p in ctx.pages), "должен быть откат на goto(origin)"