Об одном способе веб-скрапинга сайтов, защищенных Cloudflare

Kate

Administrator
Команда форума
Сразу оговорюсь, что описанное ниже носит исключительно информационно-образовательный характер, и не имеет целью нанесение какого-либо ущерба компаниям, использующим защиту из заголовка статьи. По этим же причинам фокусировка статьи именно на том, как получить заветный html «как из браузера» в автоматизированном режиме, и здесь не будет идти речь о каких-то массовых распараллеливаниях через proxy и VPN, подкладываниях отпечатков ("finger prints") браузеров и т д. Ещё сразу отмечу, что в скрапинге я являюсь человеком случайным, и возможно часть поданной информации будет являться «жутким баяном» для тех, кто давно пребывает «в теме», но, надеюсь, каждый читатель найдёт здесь хотя бы часть новой/полезной информации.

В качестве примера сайта для скрапинга был выбран OZON.RU. Да, в период массового импортозамещения компании из России приходится пользоваться надежным американским сервисом Cloudflare, по-видимому, отечественные аналоги ещё не подоспели или не обладают сравнимым функционалом. «Зачем скрапить OZON?» — спросит некоторый читатель, и будет частично прав, ведь, не так давно от момента написания этой статьи они выпустили прекрасный Statistics API. Нюанс в том, что доступ к нему нужно запрашивать на сайте для партнеров OZON, оставляешь заявку, и её должны обработать в течение 5 рабочих дней. Я попробовал отправить заявку, но по факту её так никто и не обработал ни за 5, ни за большее количество дней, возможно, потому что партнером OZON я не являюсь. Известно, что API платное, и ценовых политик в публичном доступе нигде нет.

Несмотря на наличие максимально возможного набора tool'ов и библиотек для парсинга/скрапинга (нужное подчеркнуть 🙂) в экосистеме Python, я решил пройти свой путь на платформе .NET (и, соответственно, C#), как наиболее мне близкой персонально-исторически, однако доступная для исследований машина была только под управлением MacOS, поэтому буду указывать некоторые нюансы запуска решений на .NET под Unix-подобными ОС, и, в частности, на примере MacOS.

Итак, сначала я вообще глубоко не прошаривался в защищенности платформы какими-либо сервисами, и наивно пробовал отправлять на OZON обыкновенные web request'ы одним из нескольких доступных вариантов в .NET'е. Все стандартные header'ы вроде User-Agent и иже с ними с установленными «социально-ожидаемыми» значениями никак не помогут обойти блокировки Cloudflare. Чуть лучше обстоят дела с правильными cookie в web request'е, но где ж их взять, если хочешь строить browser-less решение, а одними лишь web request'ами сыт не будешь правильные cookie не получишь никогда.

В общем, было принято решение таки запускать браузер как необходимое зло, чтобы добраться до заветного html в response'е. И на эту тему Интернет завален решениями на основе Selenium. К сожалению, Selenium web driver является достаточно «грязным» решением, он оставляет целый набор «следов» при запуске браузера, например, устанавливает некоторые свойства в объекте window.cdc, прописывается в window.navigator.userAgent и ещё целый набор прочих «прелестей». Всё это уверенно и радостно любит отслеживать anti-bot решение от Cloudflare, поэтому отфутболит такой automation-controlled браузер либо сразу же, либо - в лучшем случае - предложит captcha, и не факт, что даже правильное решение этой captcha пропустит вас далее. По сути, предложение решить captcha - уже плохой знак, и чаще всего серьезный блокер для продвижения далее, поэтому даже подходы, направленные на решение captcha, в большинстве случаев являются тупиковыми 🤷🏻‍♂️ (по крайней мере, в случае с защитой от Cloudflare это, как правило, так и оказывается).

Чтобы обходить все возможные и невозможные web driver detections, народные умельцы создали решение под названием Undetected ChromeDriver (здесь ссылка для экосистемы Python). Решение повторяет все возможности обычного Chrome Driver'а, при этом при помощи патчинга библиотек, различных hook'ов на некоторые события, установки определенных опций при старте браузера и т д пытается замести все следы, которые оставляет после себя обычный Chrome Driver.

Народные умельцы портировали это решение и под .NET: здесь ссылка на github. Впрочем, поиск на Nuget по слову UndetectedChromeDriver выдает аж целых три различных пакета, правда, самым популярным является тот, ссылка на исходники которого дана выше. Как ни крути, хоть в target framework'ах ставят .NET Standard 2, и в некоторых даже .NET 5, большинство решений под .NET всё же тестируется в основном под Windows. Да, внутри кода зашиты всякие вещи вроде "после скачивания исполняемого файла chromedriver на Linux или MacOS выдай ему chmod +x", но по факту они почти не имеют толку. Попытка этой библиотеки скачать правильную версию chromedrive для установленной локально версии Chrome, не увенчалась успехом. Скачивание же chromedriver вручную (выковыривая ссылки из JSON на googlechromelabs.github.io) не только не нравится самому Chrome'у (выдавал кучу предостережений о том, что этого делать не сто́ит), но и запускать его на MacOS удалось только после принудительного переподписывания. Впрочем, желание библиотеки запустить ChromeDriverService на localhost разбилось вдребезги об ошибку Cannot start the driver service on http://localhost:xxxxx/. Все танцы с бубном её починить не увенчались успехом, а в ответах на stackoverflow и подавно указано, что этот service (начиная с таких-то версий Selenium) можно вообще не стартовать. Худо-бедно, без старта service'а мне удалось запустить что-то, запускающее Chrome через ChromeDriver. Однако обойти защиту Cloudflare через это решение не удалось, я получил captcha. Solving captcha не сильно даёт продвинуться дальше, по сути на сайт OZON всё равно не пускает.

Как оказалось, дело здесь даже не в .NET, не в порте библиотеки под .NET, и не в том, что какой-то service не смог стартануть на localhost. И в экосистеме Python давно знают об этом. Cloudflare научился обнаруживать даже UndetectedChromeDriver, и теперь есть новое решение - stealth-обёртка. Можно обернуть свой ChromeDriver в stealth, и никакой Cloudflare об этом не узнает! В данном случае, портов под .NET я не нашёл, но, возможно, это и к лучшему.

У stealth и Cloudflare идёт постоянная борьба, т.к. последним stealth как кость в горле. Иногда stealth всё-таки проигрывает, но потом фиксит недостатки своего решения. Есть примеры того, когда stealth не сработал. Но ввиду того, что последняя статья датируется годом назад от написания нынешней, я полагаю, что проблемы уже могли решиться, т.к. в Интернете есть много положительных отзывов об обходе защиты Cloudflare через stealth. В любом случае, я предполагаю, что эта борьба продолжится: Cloudflare продолжит искать прочие следы автоматизации, а stealth и иже с ними будут пытаться их покрывать.

Если проблема решается так со скрипом, то может не смотреть в сторону Selenium? Есть ли другие пути? Наверняка, да! Очень хороший разбор того, как в принципе работает защита Cloudflare, обзор вариантов обхода блокировки и решение при помощи расширения браузера был представлен год назад (от написания этой статьи) и на Хабре (кстати, коллега тоже описывал решение на .NET). Решение мне понравилось, но показалось весьма ёмким для реализации. Есть ещё всякие платные сервисы вроде ZenRows и проч., но в моём случае речь не идёт о заработке с парсинга, поэтому траты не имеют смысла.

Game changer'ом в моём подходе стал ответ на этот вопрос на stackoverflow. Возможно, вы знали, что Chrome поддерживает не только свои внутренние Debugger tools, но и предоставляет интерфейс для debugging'а при помощи внешних IDE. Доступ к этому интерфейсу можно получить, если указать необходимые параметры при старте Chrome, при этом браузер сам поднимает локальный service на http://localhost:9222 (порт по умолчанию, можно поменять), и далее по специальному протоколу идёт общение через web sockets, где можно делать практически всё то же, что и во внутренних debugger tools. На десктопных версиях Chrome такое решение используют нечасто, но вот если нужно подключиться к браузеру на мобильном устройстве, то remote debugging из IDE - довольно-таки распространенный сценарий. Как указано в вышеупомянутом ответе на stackoverflow, есть библиотека, поддерживающая протокол Chrome Dev Tools, и под .NET. Скорее, подобных библиотек существует даже несколько, в чём можно убедиться при помощи поиска на Nuget.

Как можно заметить из репозитория на github, библиотека по работе с Chrome Dev Tools не обновлялась лет 6 к моменту написания этой статьи, тем не менее с некоторыми tweak'ами её удалось запустить даже на MacOS. Ниже опишу весь список tweak'ов, которые мне пришлось сделать:

Под Windows авторы библиотеки пытались форсировать IPv4 при обращении к localhost в протоколе работы с web socket'ами:

// Sometimes binding to localhost might resolve wrong AddressFamily, force IPv4

endpointUrl = endpointUrl.Replace("ws://localhost", "ws://127.0.0.1");

На MacOS этого оказалось делать не нужно. При попытке зайти через 127.0.0.1 автоматически выбрасывается 403 (Forbidden).

В параметры запуска Chrome пришлось добавить --remote-allow-origins, можно поиграться даже со значением * (если у вас наружу закрыты нужные порты или трафик хорошо фильтруется). Итак, запускал Chrome со значением --remote-allow-origins=*, без этого до remote debugger'а было не пробиться (можете поэкспериментировать с более рестриктивными значениями).

Собственно, сам путь к исполняемому файлу Chrome по умолчанию ссылался на что-то "виндоусовское": C:\Program Files (x86)\Google\Chrome\Application\chrome.exe Пришлось поменять на MacOS'ное /Applications/Google Chrome.app/Contents/MacOS/Google Chrome

В библиотеке был хардкод user-data-dir на что-то в директории temp, и после каждого запуска браузера эта директория подчищалась. Мне подошёл вариант с тем, что при запуске Chrome передается отдельная user-data-dir, т.к. это не мешает использовать отдельный инстанс Chrome под текущим пользователем, и таким образом, запуск Chrome не открывает новую вкладку в твоем работающем браузере, а именнно запускает отдельный независимый процесс, якобы для другого пользователя. Однако вместо директории в папке temp я сделал обычную длительно-хранящуюся директорию и убрал логику по очистке этой директории после запуска браузера. В моём случае как раз-таки интересно, чтобы в этой директории сохранялись все возможные нужные cookie и т д между запусками барузера. В частности, мне нужно парсить информацию с ozon.ru, но я располагаюсь территориально не в России, и первичный заход на ozon.ru перенаправляет меня на версию сайта для моего региона (даже не в доменной зоне ru), поэтому мне нужно сконфигурировать ozon на доставку на территории России, вся эта информация запоминается в cookie, и никаких редиректов больше не происходит.

В примере в репозитории на github для навигации на другой сайт в Chrome предлагается использовать обычную команду Page.navigate, однако в современной версии Chrome, если находишься на пустой вкладке, то выбрасывается ошибка о нарушении same-origin policy. Можно было бы её бороть при помощи прочих параметров запуска Chrome, которые ослабляют безопасность браузера. Я решил этого не делать, а просто запускать Chrome сразу с указанием URL, на который хочу перейти, в параметрах запуска браузера.

Если подписываешься на события в DOM какой-либо страницы, то они (эти события) начинаются «сыпаться» в socket, и библиотека пытается их десериализовать в один из классов событий, определенных в кодовой базе библиотеки. Так, мне начало приходить событие Page.navigatedWithinDocument, которого не оказалось в библиотеке. При этом стала выпадать ошибка о невозможности десериализовать это незнакомое событие. Скорее всего, оно появилось в протоколе Chrome Dev Tools позже релиза библиотеки, которая давно не обновляется. Пришлось дообъявить необходимые классы для поддержки события Page.navigatedWithinDocument.

В итоге код для получения «заветного html» начал выглядеть приблизительно следующим образом:

var chromeProcessFactory = new ChromeProcessFactory(new StubbornDirectoryCleaner(),

"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome");

using (var chromeProcess = chromeProcessFactory.Create(9222, false, url))

{

ChromeSessionInfo sessionInfo = null;

while (sessionInfo == null)

{

var sessions = await chromeProcess.GetSessionInfo();

sessionInfo = sessions.SingleOrDefault(x => IsUrlMatching(x.Url));

await Task.Delay(25);

}

url = sessionInfo.Url;

var chromeSessionFactory = new ChromeSessionFactory();

var chromeSession = chromeSessionFactory.Create(sessionInfo.WebSocketDebuggerUrl);

var parseDone = new ManualResetEventSlim();

// Register for events (in this case, "Page" domain events)

// send a command to tell chrome to send us all Page events

// but we only subscribe to certain events in this session

var pageEnableResult = await chromeSession.SendAsync<Protocol.Chrome.Page.EnableCommand>();

chromeSession.Subscribe<LoadEventFiredEvent>(async loadEventFired =>

{

await Task.Delay(1000); // for OZON it's not required to wait, but for some other web sites just loading DOM is not enough, some scripts should work yet before you can start parsing

// we cannot block in event handler, hence the task

_ = Task.Run(async () =>

{

try

{

var documentNodeId = (await chromeSession.SendAsync(new GetDocumentCommand())).Result.Root.NodeId;

var getOuterHtmlCommand = new GetOuterHTMLCommand() { NodeId = documentNodeId };

var outerHtmlEncoded = (await chromeSession.SendAsync(getOuterHtmlCommand)).Result.OuterHTML;

// the html comes encoded, you may need to decode it either here - or more conveniently later - when you already extracted necessary json

// var outerHtml = System.Text.RegularExpressions.Regex.Unescape(WebUtility.HtmlDecode(outerHtmlEncoded));

// parse outerHtmlEncoded with the library of your choice, you may still use as encoded until you need to decode some JSON excerpts etc

...

}

finally

{

parseDone.Set();

}

});

});

parseDone.Wait();

}
Как видно из вышеприведенного кода, html приходит с частично закодированными атрибутами в &quot; и прочими символами. Я не рекомендую сразу это всё декодировать в полном значении document.OuterHTML, т.к. если в каких-то атрибутах на веб-странице прописан json, то поперепутываются (поперемежаются) кавычки на разных уровнях: те, которые принадлежат собственно json-участкам, и кавычки в атрибутах самого html-документа. Это сделает дальнейший парсинг практически невозможным. Гораздо удобнее сначала выделить (отпарсить) нужные значения атрибутов, а уже затем эти значения раскодировать через что-то наподобие WebUtility.HtmlDecode в строки JSON.

Нужную сессию (страницу или вкладку браузера) мы получаем в цикле while, т.к. могут происходить какие-то redirects и т д, и нам в итоге нужно взять нужную вкладку по какому-то признаку, например, полному URL или его частям, эту логику я положил в метод IsUrlMatching. Вы, наверное, спросите: «А зачем вообще выбирать вкладки? У нас же только одна вкладка». Уж не знаю, Cloudflare ли, или сам Ozon, ещё «клепает» какие-то сессии в фоне, поэтому выбрать таки придется.

Опять же, для сайта Ozon хватает подписаться на LoadEventFired, но при тестировании на некоторых других сайтах оказалось, что после события load отрабатывают ещё некоторые клиентские скрипты, и только после окончания их работы можно приступать к парсингу веб-страницы, иначе нужных элементов на ней может просто не оказаться. Для простоты я поставил на этот случай обычный Delay, но можно использовать более изощренные и надежные варианты.

Конечно, данный способ не подразумевает какой-то серьёзной автоматизации через Selenium (щёлкания на элементы и т д), он имеет свои ограничения, но позволяет, тем не менее, не наступать на ловушки против Selenium'а, является достаточно простым и подойдёт для базового скрапинга.

 
Сверху