From 5f1a2ec58560c49cb00bae678168ffe830df41ae Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 29 Aug 2026 21:24:08 +0300 Subject: [PATCH 1/2] =?UTF-8?q?perf(tradein/browser):=20=D1=8F=D0=BA=D0=BE?= =?UTF-8?q?=D1=80=D0=BD=D0=B0=D1=8F=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B2=D1=8B=D0=B4=D0=B0=D1=87=D0=B8=20=D0=B2=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BE=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D1=8F=20origin=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BA=D0=B0=D0=B6=D0=B4=D1=83=D1=8E=20=D0=BA=D0=B0=D1=80=D1=82?= =?UTF-8?q?=D0=BE=D1=87=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При 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 поведение не меняется вовсе. Сброс контекста роняет и якорь. --- tradein-mvp/browser/server.py | 85 ++++++++++++++- .../browser/test_server_reuse_context.py | 100 ++++++++++++++++++ 2 files changed, 180 insertions(+), 5 deletions(-) diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index 48c3d37c..093c5612 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 @@ -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] 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)" -- 2.45.3 From 2c29151e8507fe7e6ccc368329adf27a1a0847a7 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 29 Aug 2026 21:27:12 +0300 Subject: [PATCH 2/2] =?UTF-8?q?docs(tradein/browser):=20origin=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D0=B5=D1=82=20=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=20=D0=BA=D1=83=D0=BA=D0=B8=20=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B0,=20=D0=B0=20=D0=BD=D0=B5?= =?UTF-8?q?=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20Referer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Оба комментария про origin обещали «органическую навигацию с реальными cookies/Referer». Referer тут не при чём: playwright'овский goto() этот заголовок не шлёт вовсе, так что органической навигацией заход на origin не был никогда. Работает он через куки контекста — пропуск QRATOR, выданный на выдаче, остаётся в контексте и годится для карточки. Различие не косметическое: из «нужен Referer» следует, что origin надо переоткрывать перед каждой карточкой, а из «нужны куки» — что достаточно одного раза. Второе и делает якорная вкладка предыдущего коммита; заодно дописано, почему при reuse_context повторный заход на выдачу не нужен. --- tradein-mvp/browser/server.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index 093c5612..8fd66c4e 100644 --- a/tradein-mvp/browser/server.py +++ b/tradein-mvp/browser/server.py @@ -950,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") @@ -1492,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-аккаунт сессии, эмпирически -- 2.45.3