Merge pull request 'fix(tradein/browser): куки клались на поддомен и вечно держали QRATOR-загрузчик (#3261)' (#3262) from fix/browser-cookie-domain-and-fonts into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Successful in 2m46s
Deploy Trade-In / perimeter-smoke (push) Successful in 10s
Deploy Trade-In / deploy (push) Successful in 1m44s
Deploy Trade-In / deploy-status (push) Successful in 1s
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Successful in 2m46s
Deploy Trade-In / perimeter-smoke (push) Successful in 10s
Deploy Trade-In / deploy (push) Successful in 1m44s
Deploy Trade-In / deploy-status (push) Successful in 1s
This commit is contained in:
commit
046b0282d5
5 changed files with 205 additions and 9 deletions
|
|
@ -16,8 +16,50 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||||
libx11-xcb1 \
|
libx11-xcb1 \
|
||||||
libxtst6 \
|
libxtst6 \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
|
fontconfig \
|
||||||
|
fonts-liberation \
|
||||||
|
fonts-crosextra-carlito \
|
||||||
|
fonts-crosextra-caladea \
|
||||||
&& useradd --create-home --uid 1000 app
|
&& 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
|
WORKDIR /app
|
||||||
|
|
||||||
# Пины, а не плавающие версии: единственный лок этого сайдкара — вот эти строки.
|
# Пины, а не плавающие версии: единственный лок этого сайдкара — вот эти строки.
|
||||||
|
|
|
||||||
|
|
@ -1775,6 +1775,35 @@ async def _wait_out_pow_challenge(
|
||||||
return settled
|
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(
|
async def _get_or_create_context(
|
||||||
provider: str,
|
provider: str,
|
||||||
browser: object,
|
browser: object,
|
||||||
|
|
@ -1793,7 +1822,7 @@ async def _get_or_create_context(
|
||||||
return existing
|
return existing
|
||||||
context = await browser.new_context() # type: ignore[attr-defined]
|
context = await browser.new_context() # type: ignore[attr-defined]
|
||||||
if cookies:
|
if cookies:
|
||||||
cookie_domain = f".{urlparse(url).hostname or ''}"
|
cookie_domain = _cookie_domain(url)
|
||||||
await context.add_cookies( # type: ignore[attr-defined]
|
await context.add_cookies( # type: ignore[attr-defined]
|
||||||
[
|
[
|
||||||
{"name": name, "value": value, "domain": cookie_domain, "path": "/"}
|
{"name": name, "value": value, "domain": cookie_domain, "path": "/"}
|
||||||
|
|
@ -1876,7 +1905,7 @@ async def _fetch_once(
|
||||||
else:
|
else:
|
||||||
page = await browser.new_page() # type: ignore[attr-defined]
|
page = await browser.new_page() # type: ignore[attr-defined]
|
||||||
if cookies:
|
if cookies:
|
||||||
cookie_domain = f".{urlparse(url).hostname or ''}"
|
cookie_domain = _cookie_domain(url)
|
||||||
await page.context.add_cookies( # type: ignore[attr-defined]
|
await page.context.add_cookies( # type: ignore[attr-defined]
|
||||||
[
|
[
|
||||||
{"name": name, "value": value, "domain": cookie_domain, "path": "/"}
|
{"name": name, "value": value, "domain": cookie_domain, "path": "/"}
|
||||||
|
|
|
||||||
112
tradein-mvp/browser/test_server_cookie_domain.py
Normal file
112
tradein-mvp/browser/test_server_cookie_domain.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -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(
|
def test_reuse_context_false_still_injects_cookies_into_page_context(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> 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:
|
class _FakePageContext:
|
||||||
def __init__(self) -> None:
|
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 == [
|
assert browser.page.context.add_cookies_calls == [
|
||||||
[{"name": "sid", "value": "x", "domain": ".realty.yandex.ru", "path": "/"}]
|
[{"name": "sid", "value": "x", "domain": ".yandex.ru", "path": "/"}]
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
def test_fetch_once_injects_cookies_before_goto_with_derived_domain(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> 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()
|
page = _OriginTrackingPage()
|
||||||
server._browsers["cian"] = _OriginTrackingBrowser(page)
|
server._browsers["cian"] = _OriginTrackingBrowser(page)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|
@ -417,13 +424,13 @@ def test_fetch_once_injects_cookies_before_goto_with_derived_domain(
|
||||||
assert {
|
assert {
|
||||||
"name": "CAS_ID",
|
"name": "CAS_ID",
|
||||||
"value": "12345",
|
"value": "12345",
|
||||||
"domain": ".ekaterinburg.domclick.ru",
|
"domain": ".domclick.ru",
|
||||||
"path": "/",
|
"path": "/",
|
||||||
} in injected
|
} in injected
|
||||||
assert {
|
assert {
|
||||||
"name": "qrator_jsid2",
|
"name": "qrator_jsid2",
|
||||||
"value": "abc",
|
"value": "abc",
|
||||||
"domain": ".ekaterinburg.domclick.ru",
|
"domain": ".domclick.ru",
|
||||||
"path": "/",
|
"path": "/",
|
||||||
} in injected
|
} in injected
|
||||||
# Инъекция ДО навигации на целевой url — единственный goto остаётся ровно url.
|
# Инъекция ДО навигации на целевой url — единственный goto остаётся ровно url.
|
||||||
|
|
@ -452,7 +459,8 @@ def test_fetch_once_cookie_domain_derivation_is_generic_not_domclick_hardcoded(
|
||||||
"""Domain derivation работает для ЛЮБОГО хоста, не только domclick.ru.
|
"""Domain derivation работает для ЛЮБОГО хоста, не только domclick.ru.
|
||||||
|
|
||||||
Доказывает, что механизм провайдер-агностичен: hardcode ".domclick.ru" НЕ
|
Доказывает, что механизм провайдер-агностичен: hardcode ".domclick.ru" НЕ
|
||||||
используется — тот же код с avito.ru url выводит ".www.avito.ru".
|
используется — тот же код с avito.ru url выводит ".avito.ru", схлопывая
|
||||||
|
поддомен www ровно так же, как схлопывает ekaterinburg у Домклика (#3261).
|
||||||
"""
|
"""
|
||||||
page = _OriginTrackingPage()
|
page = _OriginTrackingPage()
|
||||||
server._browsers["avito"] = _OriginTrackingBrowser(page)
|
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]
|
injected = page.context.add_cookies_calls[0]
|
||||||
assert injected == [
|
assert injected == [
|
||||||
{"name": "sessionid", "value": "xyz", "domain": ".www.avito.ru", "path": "/"}
|
{"name": "sessionid", "value": "xyz", "domain": ".avito.ru", "path": "/"}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue