diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index 48c3d37c..8fd66c4e 100644 --- a/tradein-mvp/browser/server.py +++ b/tradein-mvp/browser/server.py @@ -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 @@ -884,9 +950,9 @@ async def fetch_handler(request: web.Request) -> web.Response: if not url: return web.json_response({"error": "missing 'url' field"}, status=400) - # origin (опционально) — same-site якорь, на который камуфокс зайдёт ПЕРЕД url - # (органическая навигация: реальные cookies/Referer вместо холодного goto, - # зеркалит /fetch-json #1917). Явного auto-derive из url здесь НЕТ (в отличие + # origin (опционально) — same-site якорь, на который камуфокс зайдёт ПЕРЕД url, + # чтобы получить пропуск QRATOR в куки контекста (Referer тут ни при чём: goto() + # его не шлёт; зеркалит /fetch-json #1917). Явного auto-derive из url здесь НЕТ (в отличие # от fetch-json) — caller не передал → остаётся None, origin-goto не выполняется, # поведение идентично прежнему (avito/cian/yandex его никогда не передают). origin: str | None = body.get("origin") @@ -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 @@ -1426,10 +1492,15 @@ async def _fetch_once( параллелятся — recycle через _relaunch_browser безопасен прямо здесь. origin (опционально) — same-site якорь (например SERP), на который камуфокс - заходит ПЕРЕД целевым url: органическая навигация с реальными cookies/Referer - вместо холодного прямого захода (зеркалит _fetch_json_once, #1917 — DomClick - card-fetch, эмпирически подтверждено вживую 2026-07-04). None (дефолт) → поведение - не меняется, ровно один goto(url) как раньше (avito/cian/yandex не передают origin). + заходит ПЕРЕД целевым url, чтобы получить пропуск QRATOR в контексте (зеркалит + _fetch_json_once, #1917 — DomClick card-fetch, эмпирически подтверждено вживую + 2026-07-04). Работает это через КУКИ контекста, а не через Referer: goto() + заголовок Referer не шлёт вовсе, так что «органической навигации» тут никогда не + было — ценность ровно в прогретом контексте. Поэтому при reuse_context origin + поднимается ОДИН раз в якорной вкладке (_ensure_anchor_page), а не переоткрывается + перед каждой карточкой: контекст уже прогрет, повторный заход — трата рукопожатия. + None (дефолт) → поведение не меняется, ровно один goto(url) как раньше + (avito/cian/yandex не передают origin). cookies (опционально) — dict cookie_name→value для инъекции ДО любой навигации (обход QRATOR-блока DomClick при валидной test-аккаунт сессии, эмпирически @@ -1483,10 +1554,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] diff --git a/tradein-mvp/browser/test_server_reuse_context.py b/tradein-mvp/browser/test_server_reuse_context.py index debc185d..fdfce776 100644 --- a/tradein-mvp/browser/test_server_reuse_context.py +++ b/tradein-mvp/browser/test_server_reuse_context.py @@ -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)"