Эта статья была полезной?
Как парсить 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.
- Browser API RuCaptcha (
Настройка проекта
-
Клонируйте репозиторий и создайте виртуальное окружение:
bashgit clone https://github.com/2scraper/transfermarkt-scraper.git cd transfermarkt-scraper python3 -m venv .venv source .venv/bin/activate # Для Windows: .venv\Scripts\activate -
Установите зависимости для выбранного движка. Не устанавливайте несколько движков в одно окружение:
bashpip install -r requirements.txt -r requirements-playwright.txt playwright install chromium -
Скопируйте файл конфигурации окружения и запустите офлайн-тесты — им не нужны ни сеть, ни браузер, ни ключи:
bashcp .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
bash tools/waf_probe.sh 10 market-values
Он отправляет десять обычных запросов и считает, на сколько из них сайт ответил капчей. Если ни одного, вам достаточно локального браузера.
Основной точкой входа является скрипт playwright_scraper.py. Вот примеры запуска для разных сценариев:
bash
# Рейтинг стоимости, три страницы, 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, метаданные запуска |
Как обрабатывается одна страница:
- Строится URL (
?page=Nначиная со второй страницы). - Парсер переходит по адресу и ждёт появления таблицы, но не дольше заданного времени. «Готово» означает больше трёх ссылок на игроков в таблице, потому что единственное совпадение может найтись на постороннем элементе ещё до того, как таблица отрисуется.
- Страница получает одно из состояний:
content,captcha,blocked,empty,not_foundилиserver_error. - Применяется правило для этого состояния.
- Парсер разбирает строки, убирает повторы по
skuи переходит к следующей странице. - Конец списка определяется так: страница за его концом повторяет одну из прежних.
Правила лежат в page_flow.py в виде данных, поэтому движки не могут разойтись в решениях:
python
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">. Каждое поле берётся из одного из трёх мест:
- Шаблоны URL:
/profil/spieler/{id},/verein/{id},/jumplist/transfers/spieler/{id}/transfer_id/{id}. Это самый надёжный источник. - Атрибуты
itempropв шапке профиля игрока:itemprop="birthDate","nationality","height". - CSS-классы сайта (
table.items,hauptlink,data-header__label) или номер столбца — во всех остальных случаях.
Таблица рейтинга
Страницы рейтинга, состава и трансферов выводят таблицу table.items. В строке рейтинга шесть ячеек верхнего уровня: место, игрок, возраст, флаги гражданства, клуб и рыночная стоимость. Ячейка игрока — это вложенная таблица table.inline-table: в первой строке ссылка с именем, во второй — позиция:
python
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
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
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
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.de220,00 Mio. €500 Tsd. €.world220,00 млн €500 тыс €.co.kr220.00 mil. €500 k €.jp220.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
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
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
python3 playwright_scraper.py --mode market-values --pages 2 --proxy-file proxies.txt --proxy-shuffle
Несколько правил, которым следует репозиторий:
-
Учётные данные никогда не попадают в командную строку браузера. Playwright получает их отдельными параметрами
proxy, поэтому их не видно вps:pythonbrowser = 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
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
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
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
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
python3 tools/browser_profile_client.py accounts # по строке на аккаунт
python3 tools/browser_profile_client.py use --account-id <id> --write-env
После этого каждый запуск идёт через удалённый браузер. Капчу AWS WAF снимает встроенное в него расширение:
python
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
{
"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
{
"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
{
"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
{
"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
"""Компактный парсер рейтинга стоимости игроков 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 решения капчи подключён как запасной вариант.