Looking for international version of our service? Go to 2captcha.com

Логотип «RuCaptcha»Перейти на главную страницу
Туториалы по обходу капчи

Эта статья была полезной?

Как парсить TRANSFERMARKT

Джерри Слиман
Джерри Слиман

Технический специалист

Сбор футбольной статистики имеет свои особенности, особенно когда речь идёт о Transfermarkt (transfermarkt.com) — одном из главных справочных сайтов о трансферном рынке. Рыночная стоимость игроков, составы клубов, суммы трансферов, контракты и выступления за сборные — всё это есть на открытых страницах и пригодится для скаутинга, аналитики фэнтези-футбола или исследований.

Чтобы собирать эти данные стабильно, недостаточно просто загрузить HTML:

  • Сайт защищён AWS WAF на CloudFront. Откроется страница или появится капча, зависит от IP-адреса, с которого отправлен запрос. С большинства адресов страница открывается сразу, но с некоторых капча появляется на каждый запрос.
  • Браузер без окна с настройками по умолчанию получает отказ ещё до AWS WAF. Chromium в режиме headless сообщает о себе HeadlessChrome, и CloudFront отвечает на это ошибкой HTTP 403.
  • Структурированных данных JSON-LD нет. Каждое поле берётся из шаблонов URL, атрибутов разметки в профиле игрока и CSS-классов самого сайта.
  • В постраничной навигации и фильтрах есть ловушки. Ссылка рейтинга на следующую страницу иногда ведёт на адрес с ответом 404. Список трансферов без предупреждения скрывает игроков дешевле 500 000 евро.
  • Одни и те же данные публикуются на пяти языках. Немецкий, русский, корейский и японский сайты по-разному записывают суммы и названия стран.

В этом руководстве мы рассмотрим, как надёжно извлекать данные Transfermarkt с помощью проекта с открытым исходным кодом 2scraper/transfermarkt-scraper: как он получает страницы, откуда берёт каждое поле и как справляется с AWS WAF. Основное внимание уделим движку Playwright — его рекомендует репозиторий.

Что мы будем создавать

Скрипт, способный работать в четырёх режимах:

  • Рейтинг стоимости (market-values): самые дорогие игроки, по 25 на странице.
  • Состав клуба (club-squad): полный состав за выбранный сезон.
  • Трансферы (transfers): список последних трансферов.
  • Профиль игрока (player): все данные одного игрока.

Извлекаемые данные делятся на два типа строк — игроки и трансферы:

  • Строки игроков (рейтинг, состав, профиль): sku (id игрока на Transfermarkt), title (имя), price и currency (рыночная стоимость в евро), rank, position, age, nationality, nationalities, club, club_id, shirt_number, а в профиле ещё full_name, birth_date, birth_place, height_m, agent, league, joined_date, contract_until, market_value_last_update, caps и international_goals.
  • Строки трансферов: sku (id трансфера), player_id, title, position, age, nationality, from_club, from_league, to_club, to_league, price (сумма) и fee_type (disclosed, loan, free или unknown).

Каждый запуск рядом с данными создаёт файл-спутник <out>.meta.json. По нему видно, полный результат или частичный либо запуск был заблокирован, а также какие страницы загрузить не удалось.

Требования

  • Python: версия 3.9 или новее.
  • Движок браузера: по умолчанию используется Playwright (Chromium). Также поддерживаются Selenium и pyppeteer. Есть и scraper_api_client.py, которому браузер не нужен.
  • Окружение: рекомендуется отдельное виртуальное окружение для каждого движка, так как их зависимости конфликтуют.
  • Опционально: Browser API RuCaptcha, прокси и API-ключ RuCaptcha. С большинства адресов сайт отдаёт страницы обычному локальному браузеру, поэтому дополнительные сервисы обычно не требуются. Они нужны, только если на запросы с вашего IP-адреса сайт отвечает капчей:
    • Browser API RuCaptcha (TRANSFERMARKT_CDP_ENDPOINT) — удалённый браузер, к которому подключаются по протоколу Chrome DevTools, со своим IP-адресом и автоматическим решением капчи. Репозиторий считает его основным способом справиться с капчей AWS WAF.
    • Прокси (TRANSFERMARKT_PROXY или --proxy-file), когда дело только в вашем IP-адресе.
    • API-ключ RuCaptcha (TWOCAPTCHA_KEY) для помощника, который создаёт адрес подключения к Browser API, и для запасного варианта — решения капчи через API.

Настройка проекта

  1. Клонируйте репозиторий и создайте виртуальное окружение:

    bash Copy
    git clone https://github.com/2scraper/transfermarkt-scraper.git
    cd transfermarkt-scraper
    python3 -m venv .venv
    source .venv/bin/activate        # Для Windows: .venv\Scripts\activate
  2. Установите зависимости для выбранного движка. Не устанавливайте несколько движков в одно окружение:

    bash Copy
    pip install -r requirements.txt -r requirements-playwright.txt
    playwright install chromium
  3. Скопируйте файл конфигурации окружения и запустите офлайн-тесты — им не нужны ни сеть, ни браузер, ни ключи:

    bash Copy
    cp .env.example .env
    python3 smoke_test.py

Парсер читает из .env четыре переменные:

Переменная Что это
TWOCAPTCHA_KEY API-ключ RuCaptcha
TRANSFERMARKT_CDP_ENDPOINT Адрес подключения к Browser API (Playwright и pyppeteer)
TRANSFERMARKT_PROXY Прокси, http://user:pass@host:port
TRANSFERMARKT_URL Необязательный --url по умолчанию

Храните учётные данные в .env, а не в командной строке: аргументы запуска видит любая программа на компьютере через ps. Кроме того, они попадают в историю оболочки и журналы CI. Команда python3 env_config.py показывает, какие переменные загружены, не выводя их значений.

Пока оставьте TRANSFERMARKT_CDP_ENDPOINT пустым. Нужен ли вам Browser API, зависит от вашего IP-адреса. Это первое, что проверяется в «Быстром старте».

Быстрый старт

Сначала проверьте, показывает ли сайт капчу на запросы с вашего IP-адреса. Для этого в репозитории есть скрипт проверки:

bash Copy
bash tools/waf_probe.sh 10 market-values

Он отправляет десять обычных запросов и считает, на сколько из них сайт ответил капчей. Если ни одного, вам достаточно локального браузера.

Основной точкой входа является скрипт playwright_scraper.py. Вот примеры запуска для разных сценариев:

bash Copy
# Рейтинг стоимости, три страницы, JSON и CSV
python3 playwright_scraper.py --mode market-values --pages 3 --format both --out out/ranking

# Состав клуба («Манчестер Сити» — клуб 281)
python3 playwright_scraper.py --mode club-squad --club-id 281 --season 2025 --out out/squad

# Последние трансферы без порога стоимости, в 4 параллельных потока
python3 playwright_scraper.py --mode transfers --min-value 0 --pages 6 --concurrency 4 --out out/transfers

# Полный профиль игрока (Эрлинг Холанд)
python3 playwright_scraper.py --mode player --player-id 418560 --out out/player

--mode сам строит нужный URL по --club-id, --season или --player-id; можно и передать --url напрямую. Адрес немецкого, русского, корейского или японского сайта работает так же: язык определяется по домену.

Как только TRANSFERMARKT_CDP_ENDPOINT задан в .env, каждый запуск идёт через Browser API. Параметр --concurrency в этом случае отклоняется с кодом выхода 2: профиль Browser API допускает только одно подключение одновременно. Для параллельного локального запуска оставьте переменную пустой или переопределите её для одной команды: TRANSFERMARKT_CDP_ENDPOINT= python3 playwright_scraper.py ….

Каждый запуск записывает <out>.json и/или <out>.csv, а также файл-спутник <out>.meta.json.

Интеграция в автоматизацию: коды выхода

Понимание кодов выхода важно для настройки cron-задач и CI. Код выхода всегда сообщает, чем закончился запуск. Запуск, который ничего не нашёл, не перезаписывает прошлый удачный файл пустым [].

Код Значение Действие для автоматизации
0 Полный результат, в том числе список, закончившийся раньше --pages Данные собраны, файл обновлён
2 Ошибка использования, например URL неподдерживаемого сайта Transfermarkt Проверьте аргументы и .env
3 Заблокировано: капча или отказ Смените IP-адрес или используйте Browser API
4 Ноль строк, и это настоящий ответ сайта (пустой состав, несуществующий id игрока) Проверьте запрос: данных действительно нет
5 Ничего не получено: истекло время загрузки, прокси не ответил или отклонил логин Проверьте прокси и подключение; причина (stop_reason) выводится в журнал запуска
6 Частичный результат: часть страниц получена, потом запуск остановился Собранные строки сохранены; неудачные страницы перечислены в .meta.json

Как работает парсер

Репозиторий разделяет две задачи: как получить страницу и что на ней написано:

Файл Назначение
playwright_scraper.py, selenium_scraper.py, puppeteer_scraper.py Движки: запускают браузер, переходят по адресу, возвращают HTML
scraper_api_client.py Четвёртый движок, без браузера, через Scraper API RuCaptcha
page_flow.py Общие для всех движков решения: состояния страницы, повторы, постраничная навигация
product_parser.py Разбор разметки Transfermarkt
captcha_solver.py Распознавание проверок и вызовы API RuCaptcha
proxy_pool.py Разбор прокси, сессии, смена IP-адреса
output_writer.py Модели строк, запись JSON и CSV, метаданные запуска

Как обрабатывается одна страница:

  1. Строится URL (?page=N начиная со второй страницы).
  2. Парсер переходит по адресу и ждёт появления таблицы, но не дольше заданного времени. «Готово» означает больше трёх ссылок на игроков в таблице, потому что единственное совпадение может найтись на постороннем элементе ещё до того, как таблица отрисуется.
  3. Страница получает одно из состояний: content, captcha, blocked, empty, not_found или server_error.
  4. Применяется правило для этого состояния.
  5. Парсер разбирает строки, убирает повторы по sku и переходит к следующей странице.
  6. Конец списка определяется так: страница за его концом повторяет одну из прежних.

Правила лежат в page_flow.py в виде данных, поэтому движки не могут разойтись в решениях:

python Copy
STATE_POLICY = {
    "content":      {"retry": False, "solve": False, "blocked": False},
    "blocked":      {"retry": True,  "solve": False, "blocked": True},
    "captcha":      {"retry": True,  "solve": True,  "blocked": True},
    "empty":        {"retry": False, "solve": False, "blocked": False},
    "not_found":    {"retry": False, "solve": False, "blocked": False},
    "server_error": {"retry": False, "solve": False, "blocked": False},
}

Здесь retry означает «попробовать ещё раз с другого IP-адреса». Если игрока нет на сайте, это и есть ответ сайта, поэтому not_found не повторяется и не оплачивается. При ошибке 5xx запрос повторяется с того же IP-адреса, а затем страница записывается как ошибка сервера, но никогда — как блокировка.

Извлечение данных

Без JSON-LD: откуда берутся данные

Ни на одном из типов страниц, с которыми работает репозиторий, нет блока <script type="application/ld+json">. Каждое поле берётся из одного из трёх мест:

  1. Шаблоны URL: /profil/spieler/{id}, /verein/{id}, /jumplist/transfers/spieler/{id}/transfer_id/{id}. Это самый надёжный источник.
  2. Атрибуты itemprop в шапке профиля игрока: itemprop="birthDate", "nationality", "height".
  3. CSS-классы сайта (table.items, hauptlink, data-header__label) или номер столбца — во всех остальных случаях.

Таблица рейтинга

Страницы рейтинга, состава и трансферов выводят таблицу table.items. В строке рейтинга шесть ячеек верхнего уровня: место, игрок, возраст, флаги гражданства, клуб и рыночная стоимость. Ячейка игрока — это вложенная таблица table.inline-table: в первой строке ссылка с именем, во второй — позиция:

python Copy
table = soup.select_one("#yw1 table.items") or soup.select_one("table.items")
for tr in table.select("tbody > tr"):
    cells = tr.find_all("td", recursive=False)
    if len(cells) < 6:
        continue                                  # служебные строки и итог таблицы
    link = cells[1].select_one('a[href*="/profil/spieler/"]')
    player_id = re.search(r"/profil/spieler/(\d+)", link["href"]).group(1)
    position = cells[1].select("table.inline-table tr")[1].get_text(strip=True)
    flags = [img["title"] for img in cells[3].select("img.flaggenrahmen")]
    club = cells[4].select_one('a[href*="/verein/"]')
    price = parse_market_value(cells[5].get_text())

У игрока с двумя гражданствами два флага. Первый попадает в nationality, все — в nationalities.

Суммы

На всех языковых сайтах суммы указаны в евро. Отличается только запись. Если оценки нет, вместо суммы стоит дефис:

python Copy
MONEY_RE = re.compile(r"€\s*(\d+(?:[.,]\d+)?)\s*(bn|m|k)?", re.I)

def parse_market_value(text):
    m = MONEY_RE.search(text or "")
    if not m:
        return None                     # «-» означает, что оценки нет, а не 0
    amount = float(m.group(1).replace(",", ""))
    return amount * {"bn": 1e9, "m": 1e6, "k": 1e3}[m.group(2).lower()] if m.group(2) else amount

None здесь важен. В составе клуба у молодых игроков часто ещё нет рыночной стоимости. Например, в составе «Манчестер Сити» было 43 игрока, а стоимость была указана только у 36. Если остальным семи записать 0, они окажутся среди «ничего не стоящих», а не среди «неизвестных».

Профили игроков

Шапка профиля — это header.data-header. Дата рождения, гражданство и рост берутся из её атрибутов itemprop. Позиция, дата перехода в клуб и окончание контракта — из элементов шапки с подписями; подпись — это текст до первого двоеточия:

python Copy
header = soup.select_one("header.data-header")
birth = header.select_one('[itemprop="birthDate"]')
height = header.select_one('[itemprop="height"]')
club = header.select_one("span.data-header__club a")
agent = header.select_one("li.data-header__label a[href*='/berater/']")
labels = {}
for el in header.select("li.data-header__label, span.data-header__label"):
    content = el.select_one("span.data-header__content")
    if content:
        key = el.get_text(" ", strip=True).split(":", 1)[0].strip().lower()
        labels[key] = content.get_text(" ", strip=True)       # "joined", "contract expires", …

Полное имя и место рождения берутся из блока div.info-table профиля. Все даты приводятся к виду YYYY-MM-DD. Это важно для корейского и японского сайтов, где год идёт первым.

Ловушка постраничной навигации

Ссылка рейтинга на следующую страницу иногда имеет «галерейный» вид, …/galerie/<id>/page/10//page/2, и по этому адресу сайт отвечает 404. В наших тестах так было в 3 запусках из 8. Парсер, который идёт по ссылке сайта, в таких запусках останавливается на первой странице. Обычный адрес ?page=N каждый раз отдавал настоящие страницы, поэтому репозиторий игнорирует галерейную ссылку и строит адрес сам. В журнале он это отмечает:

text Copy
page 1's next-link is the gallery shape … fetching ?page=2 instead

Адрес за концом списка, напротив, не отвечает 404: сайт снова показывает последнюю страницу. Поэтому конец списка определяется по данным, а не по разметке: если страница не добавила ни одного нового sku, список закончен.

Скрытый фильтр в трансферах

К списку последних трансферов применяется невидимый фильтр. По умолчанию в нём только игроки со стоимостью от 500 000 евро (minMarktwert=500000); в спокойный день они не занимают и одной страницы, поэтому навигации нет вовсе. Без фильтра тот же список занимал несколько страниц — около сотни трансферов. --min-value 0 снимает порог. В метаданных записано, с каким фильтром шёл запуск: по ним видно, что 20 строк — это не весь список.

Пять языковых сайтов

У .de, .world (русский), .co.kr и .jp та же структура страниц и те же id, отличается только текст. Парсер приводит его к единому английскому словарю:

  • Формат сумм для миллионов и тысяч определяется по сайту:

    Сайт Миллионы Тысячи
    .com €220.00m €500k
    .de 220,00 Mio. € 500 Tsd. €
    .world 220,00 млн € 500 тыс €
    .co.kr 220.00 mil. € 500 k €
    .jp 220.00 m € 500 千 €
  • Позиции и страны переводятся по таблицам, взятым с самого сайта. Выпадающие списки фильтров на странице рейтинга содержат все 254 страны и 16 позиций с одинаковыми значениями на каждом домене, поэтому сопоставление по значению даёт полный словарь.

  • Названия клубов не переводятся. Их список не ограничен, поэтому в составе, собранном с .jp, клубы остаются на японском.

Сравнение одной и той же страницы рейтинга на четырёх сайтах дало тех же 25 игроков с одинаковыми значениями во всех полях, кроме club.

Защита от ботов на Transfermarkt

Сайт защищён AWS WAF на CloudFront. Откроется ли страница, зависит от двух вещей.

1. Браузер должен выглядеть обычным

Chromium без окна с настройками по умолчанию передаёт в user agent HeadlessChrome. При проверке CloudFront отвечал на это ошибкой HTTP 403 «The request could not be satisfied», ещё до AWS WAF. Тот же браузер с обычным user agent Chrome получал страницу целиком:

python Copy
browser = pw.chromium.launch(headless=True)
ua = (f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
      f"(KHTML, like Gecko) Chrome/{browser.version} Safari/537.36")
page = browser.new_context(user_agent=ua).new_page()

Репозиторий делает это при каждом запуске локального браузера.

2. Капча зависит от IP-адреса

AWS WAF отвечает одним из двух способов и указывает выбранный в заголовке x-amzn-waf-action:

Действие Что вы получаете Что помогает
challenge Фоновая проверка на JavaScript, без виджета Браузер проходит её сам, примерно за секунду
captcha HTTP 405 и виджет капчи Автоматическое решение в Browser API (см. ниже)

В нашей проверке капча зависела только от IP-адреса, с которого пришёл запрос. Она не зависела ни от клиента (curl, локальный или удалённый браузер), ни от запрошенной страницы, ни от частоты запросов:

  • С IP-адресов нескольких стран капча появлялась на каждый запрос: ОАЭ, Гонконга, Сингапура, Индонезии, Вьетнама, Саудовской Аравии, Филиппин и Китая.
  • В остальных 32 проверенных странах капча появлялась примерно на 2% адресов. С такого адреса она появлялась и при повторных запросах через несколько минут.
  • 30 запросов подряд с одного IP-адреса не вызвали капчу ни разу.

Поэтому проверка из «Быстрого старта» идёт первой. Если с вашего IP-адреса капча не появляется, больше ничего не нужно. Если появляется, смените IP-адрес или используйте Browser API.

Признаки проверяются в порядке их надёжности. Надёжнее всего заголовок ответа. Когда движок не видит заголовков (Selenium и pyppeteer читают DOM), решают признаки на самой странице:

python Copy
WAF_ACTION_HEADER = "x-amzn-waf-action"
AWS_WAF_MARKERS = ("captcha.awswaf.com", "token.awswaf.com", "window.gokuProps")

Важная деталь: расширение Browser API для решения капчи встраивает свои скрипты в каждую страницу. В адресах этих скриптов встречаются те же названия, что и в признаках проверки. Репозиторий убирает теги <script>, встроенные расширением, до проверки признаков. Иначе любая обычная страница выглядела бы как проверка.

Использование прокси

Парсер поддерживает прокси: --proxy принимает один адрес, --proxy-file — список:

bash Copy
python3 playwright_scraper.py --mode market-values --pages 2 --proxy-file proxies.txt --proxy-shuffle

Несколько правил, которым следует репозиторий:

  • Учётные данные никогда не попадают в командную строку браузера. Playwright получает их отдельными параметрами proxy, поэтому их не видно в ps:

    python Copy
    browser = pw.chromium.launch(proxy={"server": "http://host:port",
                                        "username": user, "password": password})
  • Один логин — несколько IP-адресов. Для шлюза резидентных прокси RuCaptcha --proxy-sessions N создаёт из одного логина N сессий с постоянным IP-адресом (у каждой свой id в -session-). С --concurrency каждый поток получает свой IP-адрес.

  • Заблокированная страница открывается в новом браузере с новым IP-адресом, а не в той же сессии с новым адресом: cookie, выданные одному адресу и предъявленные с другого, выглядят подозрительнее, чем каждый адрес по отдельности.

  • Об отклонённом логине парсер сообщает сразу. Браузер никогда не сообщает, что прокси отклонил его учётные данные: он просто ждёт, пока истечёт время загрузки страницы. Перед первой страницей репозиторий отправляет прокси один запрос CONNECT. Ответ 407 Proxy Authentication Required останавливает запуск за секунду-две с stop_reason: proxy_auth_refused, а не после трёх попыток, каждая из которых длится до истечения времени ожидания.

Selenium вообще не умеет передавать учётные данные прокси, поэтому с ним нужен прокси, который разрешает доступ вашему компьютеру по IP-адресу. Если в адресе прокси указаны логин и пароль, репозиторий удаляет их и предупреждает об этом.

Работа с CAPTCHA (AWS WAF)

Других проверок, кроме капчи AWS WAF, сайт в наших тестах не показывал. Вот как репозиторий с ней работает.

1. Обнаружение

Заголовок x-amzn-waf-action называет действие. На самой странице капча (в отличие от тихой проверки) загружает captcha.js с домена *.awswaf.com:

python Copy
WAF_SCRIPT_RE = re.compile(r"<script[^>]+src=[\"']([^\"']*\.awswaf\.com/[^\"']*?(challenge|captcha)\.js)")
scripts = {kind: src for src, kind in WAF_SCRIPT_RE.findall(html)}
has_widget = "captcha" in scripts

На странице challenge виджета нет, решать нечего. Парсер даёт браузеру до 15 секунд, чтобы тот прошёл проверку сам, и никогда за неё не платит.

2. Параметры

Всё, что нужно для решения, страница выводит в window.gokuProps:

python Copy
props = json.loads(re.search(r"window\.gokuProps\s*=\s*(\{.*?\})\s*;", html, re.S).group(1))
website_key, iv, context = props["key"], props["iv"], props["context"]

Если хотя бы одного из трёх значений нет, репозиторий не создаёт задачу. API без возражений принимает и такие задачи: за решение пришлось бы заплатить, хотя оно не сработает.

3. Отправка на решение — с IP-адреса браузера

RuCaptcha принимает эту капчу как задачу AmazonTask. Решение привязано к IP-адресу, с которого оно получено, поэтому в задачу передаётся тот же прокси, что и у браузера:

python Copy
task = {
    "type": "AmazonTask",                       # у AmazonTaskProxyless прокси нет
    "websiteURL": page_url,
    "websiteKey": website_key,
    "iv": iv,
    "context": context,
    "challengeScript": scripts.get("challenge"),
    "captchaScript": scripts.get("captcha"),
    "proxyType": "http", "proxyAddress": host, "proxyPort": port,
    "proxyLogin": login, "proxyPassword": password,
}

Задача отправляется на https://api.rucaptcha.com/createTask, результат забирается с https://api.rucaptcha.com/getTaskResult. Без прокси сервис решает капчу со своего IP-адреса. В таком случае приходил existing_token: на запросы с адреса самого сервиса сайт капчу не показывал, так что решать было нечего.

4. Использование решения

В решении приходит captcha_voucher, но это ещё не токен. Записанный в cookie aws-waf-token, он отклонялся. Репозиторий обменивает его так же, как это делает сам виджет, — через адрес /voucher рядом с challenge.js страницы — и записывает полученный токен:

python Copy
voucher_url = scripts["challenge"].rsplit("/", 1)[0] + "/voucher"
token = requests.post(voucher_url,
                      data=json.dumps({"captcha_voucher": voucher, "existing_token": existing}),
                      headers={"Content-Type": "text/plain;charset=UTF-8"},
                      proxies=proxies, timeout=30).json()["token"]
page.context.add_cookies([{"name": "aws-waf-token", "value": token, "url": page_url}])
page.reload()

Это запасной вариант. На этом сайте он не подтверждён. На IP-адресах, с которых капча появлялась на каждый запрос, ваучер приходил примерно за 10 секунд в 4 попытках из 6, но затем страница снова показывала капчу. Причину установить не удалось. В локальном браузере репозиторий отправляет капчу на решение сразу, как только её встречает. Через Browser API — только если капча осталась на месте после того, как встроенное автоматическое решение браузера сообщило об успехе.

Другие проверки: поддержаны, но не встречались

Репозиторий распознаёт также признаки Cloudflare, DataDome, PerimeterX/HUMAN, reCAPTCHA и hCaptcha. На этом сайте ни одна из них не встретилась. Страница с любой из них записывается как блокировка или капча, а не разбирается как пустой результат. Для reCAPTCHA v2 и v3 подключено и решение (RecaptchaV2TaskProxyless, RecaptchaV3TaskProxyless), для остальных есть только распознавание.

Использование Browser API RuCaptcha (CDP)

Browser API — это удалённый Chromium, к которому подключаются по протоколу CDP. У него свой IP-адрес и постоянный профиль, поэтому он устраняет обе причины сразу: браузер выглядит обычным, и запросы идут не с вашего адреса.

Вспомогательный скрипт из репозитория получает адрес подключения через API сервиса и записывает его в .env, не выводя на экран:

bash Copy
python3 tools/browser_profile_client.py accounts                # по строке на аккаунт
python3 tools/browser_profile_client.py use --account-id <id> --write-env

После этого каждый запуск идёт через удалённый браузер. Капчу AWS WAF снимает встроенное в него расширение:

python Copy
browser = pw.chromium.connect_over_cdp(endpoint, timeout=30000)
context = browser.contexts[0]
page = context.pages[0] if context.pages else context.new_page()
cdp = context.new_cdp_session(page)
cdp.send("Captcha.setAutoSolve", {"autoSolve": True, "options": [{"type": "*"}]})

По нашим замерам, автоматическое решение справилось с 5 капчами из 5, на каждую ушло от 30 до 63 секунд. С выключенным автоматическим решением (--no-autosolve) тот же адрес подключения оставался заблокированным во всех 8 перезагрузках. Но на IP-адресах, с которых капча появлялась на каждый запрос, автоматическое решение сообщало об успехе, а страница снова показывала капчу — как и с решением через API. В таком случае надёжно помогает только другой IP-адрес.

Что ещё нужно знать:

  • Не перезагружайте страницу во время решения. Получив токен, расширение само переходит на страницу. Перезагрузка в тот же момент заканчивается ошибкой net::ERR_ABORTED. Репозиторий вместо этого ждёт таблицу.
  • Одно подключение на профиль. Второе подключение к занятому профилю отклоняется с HTTP 500 и profile_locked. Для параллельных запусков нужен свой профиль на каждый. По этой же причине репозиторий не разрешает --concurrency вместе с --cdp-endpoint.
  • После отказа стоит попробовать снова. Сервис называет причину отказа в теле ответа 500 (profile_locked, proxy_timeout, proxy_error, fingerprint_error). Такие отказы приходят сразу, без долгого ожидания, поэтому повторное подключение отнимает всего несколько секунд. Если отказ не исчезает, репозиторий указывает его причину в подсказке.

Без браузера: Scraper API

scraper_api_client.py получает те же страницы через Scraper API RuCaptcha, без локального браузера. Он использует тот же разбор и те же состояния страницы. В тестах он вернул те же строки трансферов, что и браузерные движки. Загрузка одной страницы может занимать от нескольких секунд до минуты с лишним, поэтому у клиента есть параметр --timeout.

Масштабирование парсера

  • Параллельно — только трансферы. Страницы списка трансферов открываются по обычному адресу ?page=N, поэтому --concurrency N загружает их параллельно. Первая страница всегда загружается отдельно: от неё зависит, можно ли открывать остальные напрямую. У каждого потока свой браузер и один IP-адрес прокси на всё время работы, поэтому синхронизировать потоки между собой не нужно.
  • Больше потоков — больше IP-адресов. Без набора прокси N потоков отправляют с одного адреса в N раз больше запросов. Репозиторий об этом предупреждает.
  • Browser API параллельно — это несколько профилей, по одному на запуск.
  • Повторы ограничены, неудачи видны. --retries считает попытки, а не повторы. Неудачные страницы перечислены в метаданных по номерам.
  • Паузы. --delay задаёт паузу между страницами. Не стоит рассчитывать на «безопасную» частоту запросов: в наших тестах частота запросов капчу не вызывала, но это наблюдение касается только тех дней и адресов, которые мы проверяли.

Устранение неполадок

Проблема Возможная причина Решение
HTTP 403 «The request could not be satisfied» в вашем скрипте HeadlessChrome в user agent браузера без окна Задайте обычный user agent Chrome; репозиторий так и делает
Код выхода 3, x-amzn-waf-action: captcha На запросы с вашего IP-адреса AWS WAF отвечает капчей Запустите tools/waf_probe.sh; смените IP-адрес или используйте Browser API
--pages 5 по трансферам возвращает одну страницу Порог сайта по умолчанию — 500 000 евро --min-value 0
Рейтинг останавливается на первой странице Галерейная ссылка на следующую страницу (404) Используйте репозиторий: он строит ?page=N
Код выхода 2 с --concurrency В .env задан TRANSFERMARKT_CDP_ENDPOINT Для параллельного запуска оставьте переменную пустой
Код выхода 5, proxy_auth_refused Прокси ответил 407 на логин Проверьте учётные данные прокси; для Selenium нужен прокси с доступом по IP
HTTP 500 profile_locked при подключении Профиль Browser API занят другим запуском Дождитесь его окончания или возьмите другой профиль
price равен null Стоимость не опубликована (часто у молодых игроков) Так и должно быть: это не 0
pyppeteer на Apple silicon: «Browser closed unexpectedly» Chromium, который pyppeteer скачивает сам, собран под x86_64 --chromium-path к установленному Chrome
--out в несуществующую папку — Папка создаётся автоматически

Пример вывода

Фрагменты реального вывода в формате JSON. Строка рейтинга:

json Copy
{
  "source": "www.transfermarkt.com",
  "url": "https://www.transfermarkt.com/x/profil/spieler/937958",
  "sku": "937958",
  "title": "Lamine Yamal",
  "price": 220000000.0,
  "currency": "EUR",
  "price_source": "listing",
  "category": "market-values",
  "rank": 1,
  "page": 1,
  "position": "Right Winger",
  "age": 19,
  "nationality": "Spain",
  "nationalities": ["Spain"],
  "club": "FC Barcelona",
  "club_id": "131"
}

В профиле игрока добавляются поля шапки:

json Copy
{
  "sku": "418560",
  "title": "Erling Haaland",
  "price": 220000000.0,
  "price_source": "detail",
  "category": "player",
  "position": "Centre-Forward",
  "shirt_number": 9,
  "club": "Manchester City",
  "full_name": "Erling Braut Håland",
  "birth_date": "2000-07-21",
  "birth_place": "Leeds",
  "height_m": 1.95,
  "league": "Premier League",
  "league_id": "GB1",
  "joined_date": "2022-07-01",
  "contract_until": "2034-06-30",
  "market_value_last_update": "2026-07-22",
  "caps": 59,
  "international_goals": 65
}

Строка трансфера:

json Copy
{
  "url": "https://www.transfermarkt.com/jumplist/transfers/spieler/1072321/transfer_id/6656507",
  "sku": "6656507",
  "player_id": "1072321",
  "title": "Moisés Paniagua",
  "price": 2000000.0,
  "currency": "EUR",
  "category": "latest-transfers",
  "position": "Left Winger",
  "age": 19,
  "nationality": "Bolivia",
  "from_club": "Club Always Ready",
  "to_club": "Wydad Casablanca",
  "to_league": "Botola Pro Inwi",
  "fee_type": "disclosed"
}

И файл метаданных рядом с рейтингом:

json Copy
{
  "mode": "market-values",
  "status": "complete",
  "stop_reason": "completed",
  "pages_requested": 3,
  "pages_completed": 3,
  "pages_failed": [],
  "products": 75,
  "start_url": "https://www.transfermarkt.com/spieler-statistik/wertvollstespieler/marktwertetop"
}

Позиции и страны в результате всегда на английском, даже если страница взята с русского или японского сайта.

Полный компактный пример кода

Скрипт ниже — сокращённая версия логики репозитория в одном файле для одного режима: рейтинга стоимости на английском сайте. Скрипт задаёт обычный user agent, обрабатывает оба действия AWS WAF, ждёт готовности страницы, разбирает строки и проверяет конец списка. Он работает в локальном Chromium или через Browser API RuCaptcha, если задан TRANSFERMARKT_CDP_ENDPOINT. Другие режимы и языки, прокси и решение капчи через API есть в репозитории.

python Copy
"""Компактный парсер рейтинга стоимости игроков Transfermarkt.

    pip install playwright beautifulsoup4 && playwright install chromium
    python3 transfermarkt_minimal_ru.py --pages 3 --out ranking.json
"""
import argparse
import json
import os
import re
import sys
import time

from bs4 import BeautifulSoup
from playwright.sync_api import sync_playwright

RANKING_URL = "https://www.transfermarkt.com/spieler-statistik/wertvollstespieler/marktwertetop"
ROW_SELECTOR = '#yw1 table.items tbody > tr a[href*="/profil/spieler/"]'
MONEY_RE = re.compile(r"€\s*(\d+(?:[.,]\d+)?)\s*(bn|m|k)?", re.I)
SUFFIX = {"bn": 1_000_000_000, "m": 1_000_000, "k": 1_000}
GOKU_RE = re.compile(r"window\.gokuProps\s*=\s*(\{.*?\})\s*;", re.S)


# --------------------------------------------------------- состояние страницы
def waf_state(html, waf_header):
    """'captcha' (капча с виджетом), 'challenge' (тихая проверка JavaScript) или None."""
    if waf_header in ("captcha", "challenge"):
        return waf_header
    if html and GOKU_RE.search(html):
        return "captcha" if re.search(r"\.awswaf\.com/[^\"']*captcha\.js", html) else "challenge"
    return None


def rows_on_page(page):
    return page.locator(ROW_SELECTOR).count()


def wait_for_rows(page, timeout_s, minimum=4):
    """Больше трёх ссылок на игроков, а не одна: единственное совпадение может
    найтись на постороннем элементе ещё до того, как отрисуется таблица."""
    deadline = time.time() + timeout_s
    while time.time() < deadline:
        if rows_on_page(page) >= minimum:
            return True
        page.wait_for_timeout(500)
    return False


# ---------------------------------------------------------------------- разбор
def parse_money(text):
    m = MONEY_RE.search(text or "")
    if not m:
        return None                      # «-» означает, что оценки нет, а не 0
    amount = float(m.group(1).replace(",", ""))
    return amount * SUFFIX[m.group(2).lower()] if m.group(2) else amount


def parse_ranking(html, page_num):
    soup = BeautifulSoup(html, "html.parser")
    table = soup.select_one("#yw1 table.items") or soup.select_one("table.items")
    rows = []
    for i, tr in enumerate(table.select("tbody > tr") if table else []):
        cells = tr.find_all("td", recursive=False)
        if len(cells) < 6:
            continue                     # служебные строки и итог таблицы
        link = cells[1].select_one('a[href*="/profil/spieler/"]')
        if not link:
            continue
        player_id = re.search(r"/profil/spieler/(\d+)", link["href"]).group(1)
        inline = cells[1].select("table.inline-table tr")
        club = cells[4].select_one('a[href*="/verein/"]')
        flags = [img.get("title") for img in cells[3].select("img.flaggenrahmen") if img.get("title")]
        price = parse_money(cells[5].get_text())
        rows.append({
            "sku": player_id,
            "title": link.get_text(strip=True),
            "url": f"https://www.transfermarkt.com/x/profil/spieler/{player_id}",
            "rank": int(cells[0].get_text(strip=True) or 0) or None,
            "position": inline[1].get_text(strip=True) if len(inline) >= 2 else None,
            "age": int(cells[2].get_text(strip=True)) if cells[2].get_text(strip=True).isdigit() else None,
            "nationality": flags[0] if flags else None,
            "nationalities": flags or None,
            "club": (club.get("title") or club.get_text(strip=True)) if club else None,
            "club_id": re.search(r"/verein/(\d+)", club["href"]).group(1) if club else None,
            "price": price,
            "currency": "EUR" if price is not None else None,
            "page": page_num,
            "row_index": i,
        })
    return rows


# --------------------------------------------------------------------- браузер
def open_browser(pw):
    endpoint = os.environ.get("TRANSFERMARKT_CDP_ENDPOINT")
    if not endpoint:
        browser = pw.chromium.launch(headless=True)
        # Chromium без окна называет себя «HeadlessChrome», и CloudFront отвечает
        # на это ошибкой 403 ещё до AWS WAF.
        ua = (f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
              f"(KHTML, like Gecko) Chrome/{browser.version} Safari/537.36")
        return browser, browser.new_context(user_agent=ua).new_page(), False
    browser = pw.chromium.connect_over_cdp(endpoint, timeout=30000)
    context = browser.contexts[0] if browser.contexts else browser.new_context()
    page = context.pages[0] if context.pages else context.new_page()
    try:
        cdp = context.new_cdp_session(page)
        cdp.send("Captcha.setAutoSolve", {"autoSolve": True, "options": [{"type": "*"}]})
    except Exception as e:           # у обычного CDP-браузера нет домена Captcha
        print(f"автоматическое решение капчи недоступно: {e}", file=sys.stderr)
    return browser, page, True


def fetch(page, url, remote):
    """Загружает страницу и возвращает её HTML, когда таблица на месте, или None."""
    response = page.goto(url, wait_until="domcontentloaded", timeout=60000)
    waf = waf_state(page.content(), response.headers.get("x-amzn-waf-action") if response else None)
    if waf == "challenge":
        wait_for_rows(page, 15)      # браузер проходит проверку сам, примерно за секунду
    elif waf == "captcha":
        if not remote:
            print("На этом IP-адресе капча AWS WAF. Смените IP-адрес или используйте Browser API.",
                  file=sys.stderr)
            return None
        wait_for_rows(page, 90)      # Browser API решает капчу и сам перезагружает страницу
    return page.content() if wait_for_rows(page, 20) else None


# ------------------------------------------------------------------------ main
def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--pages", type=int, default=1)
    ap.add_argument("--out", default="ranking.json")
    args = ap.parse_args()

    rows, seen = [], set()
    with sync_playwright() as pw:
        browser, page, remote = open_browser(pw)
        try:
            for n in range(1, args.pages + 1):
                url = RANKING_URL if n == 1 else f"{RANKING_URL}?page={n}"
                html = fetch(page, url, remote)
                if html is None:
                    print(f"страница {n}: таблицы нет — останавливаемся", file=sys.stderr)
                    break
                new = [r for r in parse_ranking(html, n) if r["sku"] not in seen]
                if not new:              # страница за концом списка повторяет одну из прежних
                    break
                seen.update(r["sku"] for r in new)
                rows += new
                print(f"страница {n}: {len(new)} строк")
                time.sleep(2)
        finally:
            browser.close()

    if not rows:
        print("0 строк — файл не записан", file=sys.stderr)
        sys.exit(3)
    with open(args.out, "w", encoding="utf-8") as f:
        json.dump(rows, f, ensure_ascii=False, indent=2)
    print(f"сохранено строк: {len(rows)} -> {args.out}")


if __name__ == "__main__":
    main()

Тестирование и ограничения

Что проверено:

  • Компактный скрипт: три страницы в локальном Chromium и три через Browser API — каждый раз 75 строк, и все их поля совпали со строками самого репозитория для тех же страниц. Без обычного user agent локальный запуск получал HTTP 403.
  • Код для капчи — на настоящей капче. Мы отправили запрос через прокси с IP-адресом, с которого капча появляется на каждый запрос. Ответ пришёл с кодом HTTP 405 от CloudFront и заголовком x-amzn-waf-action: captcha. Страница называлась «Human Verification». Фрагменты кода из этой статьи — обнаружение, разбор gokuProps и поиск скриптов — распознали её так же, как сам репозиторий. Компактный скрипт остановился со своим сообщением о капче. На решение ничего не отправлялось.
  • Репозиторий — целиком, начиная с нового клона: все четыре режима на трёх браузерных движках и Scraper API, пять языковых сайтов, набор прокси из четырёх IP-адресов, Browser API с параллельными профилями и Docker-образ. Строки разных движков сравнивались утилитой diff_runs.py из репозитория: ничего не добавилось, не пропало и не изменилось.
  • Офлайн-тесты из репозитория: без сети, браузера и ключей.

Ограничения:

  • На IP-адресах, с которых капча появляется на каждый запрос, её не снял ни один способ. Сервис решения капчи вернул ваучер, автоматическое решение Browser API сообщило об успехе, но страница снова показала капчу. На остальных адресах автоматическое решение справилось с каждой встреченной капчей. То, что решение через API снимает капчу на этом сайте, не подтверждено.
  • Ветка компактного скрипта «капча через Browser API» не проверена. На профилях Browser API, с которыми он работал, капча не появлялась, поэтому ожидание автоматического решения ни разу не понадобилось.
  • Список стран отражает момент проверки. Какие адреса проверяет AWS WAF, решает сам сайт. Со временем это может измениться. Запустите проверку со своего адреса.
  • Названия клубов на языковых сайтах не переводятся.
  • Подписи «аренда» и «свободный трансфер» на неанглийских страницах трансферов пока не распознаются: такие строки получают fee_type: "unknown".

Заключение

Мы разобрали парсер Transfermarkt, который собирает рейтинг стоимости, составы клубов, последние трансферы и полные профили игроков на пяти языковых сайтах. По его метаданным видно, полный результат или частичный либо запуск был заблокирован.

Данные сайта — обычный HTML с устойчивыми id в адресах, поэтому разбор сводится к работе с таблицами, если знать ловушки: отсутствие JSON-LD, свой формат сумм на каждом языке и null вместо нуля, ссылку на следующую страницу, которая иногда отвечает 404, и скрытых по умолчанию дешёвых игроков в списке трансферов.

Получите ли вы страницы, зависит от того, откуда идут ваши запросы. Задайте обычный user agent и сначала проверьте свой IP-адрес. С большинства адресов хватает локального браузера. Если на запросы с вашего адреса AWS WAF отвечает капчей, используйте Browser API RuCaptcha — в наших тестах его автоматическое решение снимало капчу — или смените IP-адрес через прокси. Если капча появляется на каждый запрос, помогает только другой адрес. API решения капчи подключён как запасной вариант.