diff --git a/tradein-mvp/browser/Dockerfile b/tradein-mvp/browser/Dockerfile index 5f789592..645b05c8 100644 --- a/tradein-mvp/browser/Dockerfile +++ b/tradein-mvp/browser/Dockerfile @@ -16,8 +16,42 @@ 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-сборки образа. +RUN fc-cache -f \ + && FONT_COUNT="$(fc-list | wc -l)" \ + && echo "tradein-browser: fc-list count after install: $FONT_COUNT" \ + && [ "$FONT_COUNT" -ge 30 ] \ + && fc-match Arial | grep -qi "Liberation Sans" \ + && echo "tradein-browser: fc-match Arial -> Liberation Sans OK" + 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": "/"}