perf(tradein/browser): якорная вкладка выдачи вместо переоткрытия origin на каждую карточку #3244
2 changed files with 192 additions and 12 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue