diff --git a/tradein-mvp/browser/Dockerfile b/tradein-mvp/browser/Dockerfile index 5f789592..ca665ae6 100644 --- a/tradein-mvp/browser/Dockerfile +++ b/tradein-mvp/browser/Dockerfile @@ -16,8 +16,50 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ libx11-xcb1 \ libxtst6 \ ca-certificates \ + fontconfig \ + fonts-liberation \ + fonts-crosextra-carlito \ + fonts-crosextra-caladea \ && useradd --create-home --uid 1000 app +# Замер прод-контейнера (2026-08-29): `fc-list` отдавал 8 файлов / 3 семейства +# (только DejaVu Sans/Sans Mono/Serif), а камуфокс запускается с os="windows" +# (см. browser/server.py, _launch_browser) — браузер, называющий себя Windows, +# но не знающий Arial/Times New Roman/Calibri, легко ловится перечислением +# шрифтов (стандартная анти-бот проверка). Набор шрифтов подменить нельзя, +# как navigator/UA/экран/таймзону — он меряется рендерингом текста, поэтому +# нужны настоящие файлы шрифтов в системе. Это устранение отдельной нестыковки +# фингерпринта сама по себе, безотносительно к какому-либо конкретному замеру +# отказов площадок — с текущими отказами Домклика (домен инжектируемых кук) это +# не связано и их не чинит. +# +# fonts-liberation/fonts-crosextra-carlito/fonts-crosextra-caladea — метрически +# совместимые клоны Arial/Times New Roman/Courier New (Liberation), Calibri +# (Carlito) и Cambria (Caladea): те же ширины символов на кегль, просто другой +# рисунок. Детект по перечню шрифтов чаще всего меряет именно ширину текста +# (measureText), а не байты файла шрифта — метрический клон с алиасом +# fontconfig проходит проверку «Arial есть» неотличимо от настоящего Arial. +# Алиасы (Arial→Liberation Sans, Calibri→Carlito, Cambria→Caladea) кладёт сам +# пакет fontconfig файлом 30-metric-aliases.conf — отдельно их не пишем. +# +# Настоящие шрифты Microsoft (ttf-mscorefonts-installer) сознательно НЕ ставим: +# пакет качает их с SourceForge на этапе сборки и требует интерактивного +# принятия EULA — хрупко и недетерминированно для CI-сборки образа. +# Проверка сборки намеренно смотрит НЕ на число файлов, а на алиасы: счёт файлов +# зависит от того, что притащили соседние пакеты (первая версия этой проверки +# требовала >=30 файлов и покраснела на фактических 20 — порог был угадан, а не +# измерен). Значение имеет ровно одно: спрашиваешь Arial — получаешь метрически +# совместимый шрифт, а не «шрифта нет». Замер образа 2026-08-30: 20 файлов, +# 5 семейств (Liberation Sans/Serif/Mono, Carlito, Caladea). +RUN fc-cache -f \ + && echo "tradein-browser: fc-list = $(fc-list | wc -l) файлов, семейств: $(fc-list : family | tr , '\n' | sort -u | wc -l)" \ + && fc-match Arial | grep -qi "Liberation Sans" \ + && fc-match "Times New Roman" | grep -qi "Liberation Serif" \ + && fc-match "Courier New" | grep -qi "Liberation Mono" \ + && fc-match Calibri | grep -qi "Carlito" \ + && fc-match Cambria | grep -qi "Caladea" \ + && echo "tradein-browser: метрические алиасы Arial/TNR/Courier/Calibri/Cambria на месте" + WORKDIR /app # Пины, а не плавающие версии: единственный лок этого сайдкара — вот эти строки. diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index 579ad451..d31dd8c5 100644 --- a/tradein-mvp/browser/server.py +++ b/tradein-mvp/browser/server.py @@ -1775,6 +1775,35 @@ async def _wait_out_pow_challenge( return settled +def _cookie_domain(url: str) -> str: + """Домен для инъекции кук: РЕГИСТРИРУЕМЫЙ, а не хост из url. + + Раньше здесь было ``f".{hostname}"``, то есть для карточки + ``ekaterinburg.domclick.ru`` куки ложились на ``.ekaterinburg.domclick.ru``. + Настоящие куки площадки живут на ``.domclick.ru`` — это видно в записи ручной + сессии 29.08.2026 (``.domclick.ruqrator_jsid2``, ``.domclick.ruqrator_jsr``). + + Куки поддомена родительские НЕ заменяют, а сосуществуют с ними: как только + площадка выдаёт свежий ``qrator_jsid2`` на ``.domclick.ru``, браузер шлёт в + одном запросе ДВЕ куки с этим именем, и первой — более специфичную, нашу + протухшую. QRATOR читает её и держит страницу на 274-байтном загрузчике, + рукопожатие не завершается никогда. Замер 29.08 через узел 10, чередование, + свежий контекст на пробу: куки на поддомене — 0 успехов из 3 (все три + застряли на 274 байтах), те же куки на ``.domclick.ru`` — 3 из 3. + + Ограничение сознательное: берём два последних лейбла. Для доменов вида + ``example.co.uk`` это дало бы ``.co.uk``, но все площадки проекта — в зонах + первого уровня (domclick.ru, avito.ru, cian.ru, yandex.ru), а тащить сюда + publicsuffix-список ради гипотетического случая дороже, чем он стоит. + Хост из одного-двух лейблов возвращаем как есть. + """ + host = urlparse(url).hostname or "" + labels = host.split(".") + if len(labels) > 2: + host = ".".join(labels[-2:]) + return f".{host}" + + async def _get_or_create_context( provider: str, browser: object, @@ -1793,7 +1822,7 @@ async def _get_or_create_context( return existing context = await browser.new_context() # type: ignore[attr-defined] if cookies: - cookie_domain = f".{urlparse(url).hostname or ''}" + cookie_domain = _cookie_domain(url) await context.add_cookies( # type: ignore[attr-defined] [ {"name": name, "value": value, "domain": cookie_domain, "path": "/"} @@ -1876,7 +1905,7 @@ async def _fetch_once( else: page = await browser.new_page() # type: ignore[attr-defined] if cookies: - cookie_domain = f".{urlparse(url).hostname or ''}" + cookie_domain = _cookie_domain(url) await page.context.add_cookies( # type: ignore[attr-defined] [ {"name": name, "value": value, "domain": cookie_domain, "path": "/"} diff --git a/tradein-mvp/browser/test_server_cookie_domain.py b/tradein-mvp/browser/test_server_cookie_domain.py new file mode 100644 index 00000000..797d4d29 --- /dev/null +++ b/tradein-mvp/browser/test_server_cookie_domain.py @@ -0,0 +1,112 @@ +"""test_server_cookie_domain.py — на какой домен ложатся инжектируемые куки (#3261). + +Сайдкар клал куки на домен из url карточки: для +``ekaterinburg.domclick.ru`` это ``.ekaterinburg.domclick.ru``. Настоящие куки +площадки живут на ``.domclick.ru``. + +Куки поддомена родительские НЕ заменяют, а сосуществуют с ними: как только +площадка выдаёт свежий ``qrator_jsid2`` на ``.domclick.ru``, браузер шлёт в +одном запросе ДВЕ куки с этим именем, и первой — более специфичную, нашу +протухшую. QRATOR читает её и держит страницу на 274-байтном загрузчике. + +Замер 30.08 (прод, узлы 1 и 9, чередование плеч, по 4 пробы на плечо): +прод-поведение — 0 успехов из 4, только шрифты — 0 из 4, куки на +``.domclick.ru`` — 3 из 4. Плечо «только шрифты» тут важно: оно показывает, +что чинит именно домен кук, а не сопутствующая правка образа. + +Тестируется: + 1. поддомен схлопывается до регистрируемого домена; + 2. хост из двух лейблов остаётся как есть; + 3. оба места инъекции (переиспользуемый context и одноразовая страница) + зовут одну и ту же функцию, а не считают домен по-своему. + +camoufox НЕ запускается — browser/context/page подделаны. + +Запуск (из tradein-mvp/browser/):: + + python -m pytest test_server_cookie_domain.py -q +""" + +from __future__ import annotations + +import asyncio +import importlib.util +from pathlib import Path +from typing import Any + +import pytest + +_SERVER_PATH = Path(__file__).resolve().parent / "server.py" +_spec = importlib.util.spec_from_file_location("tradein_browser_server", _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) + + +@pytest.fixture(autouse=True) +def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(server, "_contexts", {}) + + +@pytest.mark.parametrize( + ("url", "expected"), + [ + ("https://ekaterinburg.domclick.ru/card/sale__flat__1", ".domclick.ru"), + ("https://domclick.ru/card/sale__flat__1", ".domclick.ru"), + ("https://www.avito.ru/ekaterinburg/kvartiry/1", ".avito.ru"), + ("https://spb.cian.ru/sale/flat/1/", ".cian.ru"), + # Хост без поддомена трогать нечего — возвращаем как есть. + ("https://cian.ru/sale/flat/1/", ".cian.ru"), + # Несколько уровней поддоменов схлопываются до тех же двух лейблов. + ("https://a.b.c.domclick.ru/card/1", ".domclick.ru"), + ], +) +def test_cookie_domain_collapses_to_registrable(url: str, expected: str) -> None: + assert server._cookie_domain(url) == expected + + +class _FakeContext: + def __init__(self) -> None: + self.added: list[dict[str, str]] = [] + + async def add_cookies(self, cookies: list[dict[str, str]]) -> None: + self.added.extend(cookies) + + async def new_page(self) -> Any: + raise AssertionError("не должно вызываться в этом тесте") + + +class _FakeBrowser: + def __init__(self, context: _FakeContext) -> None: + self._context = context + + async def new_context(self) -> _FakeContext: + return self._context + + +def test_reusable_context_puts_cookies_on_parent_domain() -> None: + """Переиспользуемый context (#3118) — основной путь добора карточек.""" + ctx = _FakeContext() + browser = _FakeBrowser(ctx) + asyncio.run( + server._get_or_create_context( + "domclick", + browser, + {"qrator_jsid2": "x", "qrator_jsr": "y"}, + "https://ekaterinburg.domclick.ru/card/sale__flat__2078475983", + ) + ) + assert {c["domain"] for c in ctx.added} == {".domclick.ru"} + assert {c["name"] for c in ctx.added} == {"qrator_jsid2", "qrator_jsr"} + + +def test_both_injection_sites_use_the_same_helper() -> None: + """Второе место инъекции (reuse_context=false) не должно считать домен само. + + Проверка структурная: если кто-то вернёт в любое из двух мест + ``f".{urlparse(url).hostname}"``, тест покраснеет — а поймать это + поведенчески дороже, чем оно стоит. + """ + source = _SERVER_PATH.read_text(encoding="utf-8") + assert source.count("cookie_domain = _cookie_domain(url)") == 2 + assert 'f".{urlparse(url).hostname' not in source diff --git a/tradein-mvp/browser/test_server_reuse_context.py b/tradein-mvp/browser/test_server_reuse_context.py index fdfce776..19567c1c 100644 --- a/tradein-mvp/browser/test_server_reuse_context.py +++ b/tradein-mvp/browser/test_server_reuse_context.py @@ -240,7 +240,12 @@ def test_reuse_context_false_uses_new_page_every_time() -> None: def test_reuse_context_false_still_injects_cookies_into_page_context( monkeypatch: pytest.MonkeyPatch, ) -> None: - """Golden-parity: cookies без reuse_context вливаются в page.context, как раньше.""" + """Golden-parity: cookies без reuse_context вливаются в page.context, как раньше. + + Домен с #3261 — регистрируемый (.yandex.ru), а не хост из url + (.realty.yandex.ru): оба места инъекции считают его одной функцией, иначе + поведение путей reuse_context=true/false разъехалось бы. + """ class _FakePageContext: def __init__(self) -> None: @@ -269,7 +274,7 @@ def test_reuse_context_false_still_injects_cookies_into_page_context( ) assert browser.page.context.add_cookies_calls == [ - [{"name": "sid", "value": "x", "domain": ".realty.yandex.ru", "path": "/"}] + [{"name": "sid", "value": "x", "domain": ".yandex.ru", "path": "/"}] ] diff --git a/tradein-mvp/browser/test_server_smoke.py b/tradein-mvp/browser/test_server_smoke.py index 4e358d3d..dfecc11d 100644 --- a/tradein-mvp/browser/test_server_smoke.py +++ b/tradein-mvp/browser/test_server_smoke.py @@ -395,7 +395,14 @@ def test_fetch_once_single_goto_when_origin_none(monkeypatch: pytest.MonkeyPatch def test_fetch_once_injects_cookies_before_goto_with_derived_domain( monkeypatch: pytest.MonkeyPatch, ) -> None: - """cookies передан → context.add_cookies() ДО goto(url), domain = "." + hostname.""" + """cookies передан → context.add_cookies() ДО goto(url), domain = регистрируемый. + + Домен НЕ хост из url: для карточки на ekaterinburg.domclick.ru куки ложатся + на .domclick.ru (#3261). Куки поддомена родительские не заменяют, а + сосуществуют с ними, и площадка получала две куки с одним именем — первой + нашу протухшую. Раньше этот тест закреплял ровно то поведение, которое + оказалось багом. + """ page = _OriginTrackingPage() server._browsers["cian"] = _OriginTrackingBrowser(page) monkeypatch.setattr( @@ -417,13 +424,13 @@ def test_fetch_once_injects_cookies_before_goto_with_derived_domain( assert { "name": "CAS_ID", "value": "12345", - "domain": ".ekaterinburg.domclick.ru", + "domain": ".domclick.ru", "path": "/", } in injected assert { "name": "qrator_jsid2", "value": "abc", - "domain": ".ekaterinburg.domclick.ru", + "domain": ".domclick.ru", "path": "/", } in injected # Инъекция ДО навигации на целевой url — единственный goto остаётся ровно url. @@ -452,7 +459,8 @@ def test_fetch_once_cookie_domain_derivation_is_generic_not_domclick_hardcoded( """Domain derivation работает для ЛЮБОГО хоста, не только domclick.ru. Доказывает, что механизм провайдер-агностичен: hardcode ".domclick.ru" НЕ - используется — тот же код с avito.ru url выводит ".www.avito.ru". + используется — тот же код с avito.ru url выводит ".avito.ru", схлопывая + поддомен www ровно так же, как схлопывает ekaterinburg у Домклика (#3261). """ page = _OriginTrackingPage() server._browsers["avito"] = _OriginTrackingBrowser(page) @@ -469,7 +477,7 @@ def test_fetch_once_cookie_domain_derivation_is_generic_not_domclick_hardcoded( injected = page.context.add_cookies_calls[0] assert injected == [ - {"name": "sessionid", "value": "xyz", "domain": ".www.avito.ru", "path": "/"} + {"name": "sessionid", "value": "xyz", "domain": ".avito.ru", "path": "/"} ]