Поскольку на Хабре после лавины публикаций «N в 30 строк» в 2013-м г. почти не публикуются материалы по программированию, то скажу, что этот материал для домохозяек, желающих научиться программированию, тестировщиков и просто неравнодушных людей.

В современном мире микросервисов господствует API. Но сервисов с каждым днем все больше, а API предоставляют далеко не все. Между тем данные сервисов могут быть весьма важны для анализа, бизнеса, копирования и т.д.

Посмотрим, как можно распарсить один известный сайт объявлений о продаже/аредне недвижимости максимально эффективно (и быстро), научимся обходить качпу и парсить мобильные приложения.

Внимание. Данный материал не является призывом к действию, носит информационный характер с целью повышения квалификации и увеличения качества работы тестировщиков.

Теория


Парсинг — структурирование данных, если говорить коротко. Если рассматривать классическую схему работы любого сайта, то в любом случае у нас будет хранилище данных (база данных), которое будет иметь структурированный вид. Удобный для машин, но не удобный для человека. Но поскольку сайт должен обслуживать людей, а не машин, данные нужно перевести в удобный для человека вид, но уже не очень удобный для чтения машинами. Схематично это можно изобразить так:



Всё это сферический конь в вакууме, и может быть не очень интересно, пока вдруг Вы не захотите прилично сэкономить, купив квартиру по методу линейной регрессии и Вам понадобятся данные не в няшном виде на сайте, а в конкретной SQL-таблице. А для этого нужно собрать данные.

Парсим на PHP

Итак, мы на коне. У нас есть: страницы сайта с HTML-версткой, в которую примешаны искомые данные. Чтобы вычленить наши данные, можно использовать разные методы, которые ограничиваются только вашей фантазией и функциями PHP для работы со строками, т.к. полученная HTML-страница — всего лишь строка (string):

  • Связка mb_strpos и mb_substr будет работать быстрее всего, но съест у Вас большое количество нервов и и далеко не всегда подходит. Я использую этот метод, когда стоимость высокой скорости очень высока, а другие методы не дают нужного результата.
  • Функция preg_match и регулярные выражения — мощный инструмент, я всегда начинаю с него. Но потребляемые ресурсы линейно зависят от сложности регулярного выражения. А если представить, что вы пробегаетесь по строке размером 2Мб, становится понятно, одно объявление вытягивается со страницы за 2-3 секунды. В качестве костыля к этому и предыдущему методу можно предварительно уменьшить искомую область. Например, удалить head и footer. С помощью, конечно, mb_strpos.
  • А не проще просто оперировать с деревом HTML-документа, при помощи simplexml (или другого extension)? Да, конечно! Или просто пробежаться по HTML как по массиву:

    $xml = simplexml_load_string($xmlstring);
    $json = json_encode($xml);
    $array = json_decode($json,TRUE);
    


Хм. Я уже умею кодить на jQuery, можно мне селектировать дивы контейнеры простыми селекторами, а не писать $document['body']['div']['div']['div']? Да, можно.

phpQuery — для тех кто хочет кодить на jQuery в PHP. И когда при помощи phpQuery вы получите одну строчку в вашей таблице, соответствующую одному объявлению, Вас закружит эйфория, Вы поставите Ваш скрипт парсить весь сайт, а тем временем пойдете в ближайшую пятёрочку покупать шампанское и будете мечтать о маленькой яхточке. Так пройдет ночь.

Суровая реальность


Наутро, придя к монитору, Вы увидите, что в таблице у Вас всего 2 тыс. строк, а скрипт отвалился с ошибкой Allowed Memory Size of… Bytes Exhausted…

Да, phpQuery, хоть и удобная штука, но память утекает. А скорость — самая низкая. Тогда Вам придется отказаться от удобной библиотеки и перейти на preg_match ради стабильности и скорости.

Ещё одни грабли — динамические селекторы. Запустив скрипт на следующий день, Вы поймете, что все классы изменились.



Решение: вообще не привязываться к классам. Ниже пример, как можно вычленить из циановского объявления все некоторые параметры, не привязываясь к CSS-классам.


$paramsNames = '["Общая","Жилая","Кухня","Этаж","Год постройки","Материалы стен","Этажность","Подъездов","Квартир","Средняя цена за м<sup>2</sup>","Средний возраст домов","Средняя цена за м<sup>2</sup>","Население","Название","Налог на недвижимость"]';
if( preg_match_all( '#<div[^>]*>[^<]*<div[^>]*>[^<]*(' . implode( '|', $paramsNames ) . ')</div>[^<]*<div[^>]*>(.*?)</div>#ius', $html, $matches ) ){
     /* ...... */
}

Как видите, структура верстки всегда одна и та же. Поэтому нет смысла вообще привязваться к классам, зацепимся к названиям параметров. И так, Вы опять ставите скрипт на ночь.

Капча

Наутро Вы увидите, что скрипт доработал до конца, но на сайт показывает 50 тыс. объявлений, а у Вас примерно те же 2 тыс.


Так выглядит капча ЦИАН

С капчей можно было бы посоревноваться при помощи ruCaptcha. Но только не в случае с reCaptcha. Бросьте эту идею, она провальна. Весь наш метод прямых HTTP-запросов из PHP уперся в тупик.

Есть выход


Выход заключается в том, что мы не будем делать GET-запросы прямо из PHP, а запустим браузер, и через PHP заставим его открывать нужные страницы и читать их содержимое. Когда вылетит капча, мы просто её пройдем, и будем парситься дальше. Рассмотрим технологии, которые нам понадобятся.

Selenium Webdriver — позволяет запускать браузер и управлять им по API (ссылка). Это инструмент тестировщиков, но мы ведь и тестируем, не правда ли? Обычно тестировщики используют Java. Но нам зачем эти танцы, если есть
php-webdriver — инструмент от Facebook (спасибо, Цукерберг!), предоставляющий SDK на PHP для Selenium webdriver.

Мне это интересно
Selenium Webdriver дружит не со всеми браузерами. Лично я поставил Windows XP на Oracle VirtualBox. На неё накатил Selenium и Хром. Когда нужно — запускаю виртуальную машину, и связка всегда работает. Так выглядит работающая консоль Selenium, где выводится служебная информация.



Вот как мы откроем главную страницу ЦИАН и нажмем на кнопку «Найти»


# используем хром как рабочую лошадку
$capabilities = DesiredCapabilities::chrome();
$capabilities->setCapability(ChromeOptions::CAPABILITY, $options);

$driver = RemoteWebDriver::create('192.168.1...:4444/wd/hub', $capabilities, 5000);
$driver->manage()->timeouts()->implicitlyWait(10);

# открываем сессию и сразу открываем страницу
$remote = $driver->get('https://cian.ru/');

# ищем по CSS-селектору. Да, это почти что jQuery
$remote->findElement(WebdriverBy::cssSelector('.c-filters-field-button___1EBB-'))->click();

В примере я просто вставил динамический CSS-селектор. Но так делать нельзя, нужно сначала узнать класс у кнопки «Найти», предварительно получив все содержимое в HTML, потом программно подставить. Получить содержимое страницы из Selenium можно так:

$content = $remote->findElement(WebdriverBy::cssSelector('body'))->getAttribute('innerHTML');

И вот, Вы один раз ввели капчу, наслаждаетесь потоком приятных строк, наполняющих Вашу таблицу, параллельно тестируя ЦИАН. Вам нравится процесс, хотя он идет и не очень быстро, но главное — идет! И Вы уходите спать.

Грабли


А наутро приходите, и обнаруживаете, что парсинг закончился, а в таблице стало чуть больше объявлений. Всего 3 тыс. Скрипт не может вызвать Selenium (не достучаться). Что происходит?!

А вот что.

Открывая страницу через $driver->get, Вы заполняете location history у браузера. Открывая следующую в текущей сессии, предыдущая из памяти не стирается. И браузер просто пухнет в оперативной памяти, пока не произойдет коллапс. Выход — прерывать сессию через $driver->close. Но тогда можно опять нарваться на капчу…

А что если...


И так до бесконечности. Скорость очень низкая, Selenium чересчур, постоянные срывы парсинга… Нужно искать что-то другое. Да, у сервиса бесплатных объявлений, который мы тестируем, есть ещё мобильное приложение. А значит, есть и публичное API. Нужно только добраться до URL-ов API, и успех у нас в кармане! Делать это будем, конечно, через Android.

Прослушивать трафик, чтобы протестировать мобильное приложение, можно, но сложно мало эффективно. Инструкция, как это сделать, уже есть. Гораздо более школьный метод просто установить SSL Capture.

Мне это интересно

Так выглядит интерфейс приложения одного из бесплатных сервисов объявлений недвижимости

Так выглядит запрос на получение общего количества объявлений. Да-да, это на Android.

Для того, чтобы распарсить весь тестируемый сайт (сервис), нужен всего-лишь один метод API. Вот он: api.cian.ru/offers/v1/search-offers-for-mobile-apps

И в завершение прикреплю функцию (код), при помощи которой Вы сможете уже через час получить весь массив данных. Никаких (!!!) ограничений на запросы к мобильному API нет.

Вот она

	$request = [
		'query' => [
			'engine_version' => [
				'type' => 'term',
				'value' => '2'
			],
			'limit' => [
				'type' => 'term',
				'value' => 50
			],
			'object_type' => [
				'type' => 'terms',
				'value' => [0]
			],
			'page' => [
				'type' => 'term',
				'value' => $page
			],
			'_type' => 'flatsale'
		]
	];

	$body = json_encode( $request );

	$ch = curl_init();

	curl_setopt($ch, CURLOPT_URL, "https://api.cian.ru/offers/v1/search-offers-for-mobile-apps/?new_schema=1&multioffer_version=3" );
	curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1 );
	curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1 );
	curl_setopt($ch, CURLOPT_POST,           1 );
	curl_setopt($ch, CURLOPT_HEADER,           0 );
	curl_setopt($ch,CURLOPT_ENCODING , "gzip");
	curl_setopt($ch, CURLOPT_HTTPHEADER, [
	'Authorization: simple ....',
	'Os: android',
	'BuildNumber: ',
	'VersionCode: 20630300',
	'Device: Phone',
	'ApplicationID: ....',
	'User-Agent: Cian/ (Android; ....; Phone; ....)',
	'Content-Type: application/json; charset=UTF-8',
	'Content-Length: ' . mb_strlen($body),
	// 'Host: api.cian.ru',
	'Connection: Keep-Alive',
	'Accept-Encoding: gzip',
		]);

	curl_setopt($ch, CURLOPT_POSTFIELDS,     $body ); 

	$result=json_decode( curl_exec ($ch), true );

Вместо точек нужно подставить свои данные авторизации. После этого в $result['data']['offers'] будет абсолютно вся информация по каждому объявлению в списке.

Если у Вас есть критические замечания по данному материалу, просьба написать в ЛС. Так же я всегда открыт к предложениям.

Используемые инструменты:

phpQuery
Selenium Webdriver
php-webdriver
Oracle VirtualBox

Комментарии (22)


  1. SDKiller
    28.12.2018 21:07
    +5

    Поскольку на Хабре… почти не публикуются материалы по программированию...

    А это материал по программированию, которого хабр ждал с 2013 года?


    1. KorP
      28.12.2018 21:21
      +2

      Там же дальше сказано

      материал для домохозяек

      Правда не понятно, что это за домохозяйки такие со знанием PHP и зачем им вообще циан парсить :)


      1. OnYourLips
        29.12.2018 23:23
        +1

        А кому еще кроме домохозяек придется парсить таким способом?

        Лично я бы, если бы передо мной стояла такая задача, использовал очереди, а в случаях требования высокой производительности — очереди и асинхронный ввод-вывод. Но этого в статье просто нет, а организация флоу парсинга на мой взгляд более сложная тема, чем выбор способа разбора.


  1. php_freelancer
    28.12.2018 21:45
    +1

    Зачем писать парсер на PHP? Я разрабатываю на PHP, но парсеры пишу для самых сложных сайтов, даже с Client Side Rendering на PhantomJS -> CasperJS, что в 10000 раз компактнее, удобнее, стабильнее.


    1. Dreyk
      28.12.2018 23:01
      +1

      фатальный недостаток же


    1. LazyTalent
      28.12.2018 23:49
      +2

      Если не секрет, то почему именно PhantomJS? Он же мёртв не поддерживается уже больше, чем пол года.


      1. Jameson
        30.12.2018 18:42
        +1

        Он уже полтора года как мёртв, к сожалению. Просто многие не в курсе, равно как и про puppeteer не знают.


    1. riky
      30.12.2018 19:00
      +1

      и в 10000 раз медленнее


  1. Sabubu
    29.12.2018 01:22
    +1

    Эта борьба с защитой выглядит как бессмысленная трата сил.


  1. rudinandrey
    29.12.2018 08:50

    Есть еще Simple HTML Dom для PHP библиотека для парсинга, мне она больше нравится чем phpQuery.


    1. Rukis
      30.12.2018 19:28
      +1

      А я посоветую github.com/Imangazaliev/DiDOM есть сравнение с другими в том числе с Simple HTML Dom: github.com/Imangazaliev/DiDOM/wiki/Comparison-with-other-parsers-(1.0)


  1. rjhdby
    29.12.2018 10:29

    А значит, есть и публичное API.

    А сайт у них другим API пользуется?


  1. spy45242
    29.12.2018 10:53

    Занимательно! И способ с парсингом сайта с динамическими селекторами в статье мне понравился, тоже об этом думал, но никак не доходили руки попробовать. Но! Это что касаемо классов. Вы используете регулярку типа '#<div[^>]*>[^<]*<div[^>]*>[^<]*('. Как насчет динамического контента, к примеру когда на сайте части контента разделены тегом , а через минуту уже тегом

    с динамическим классом? То есть меняються не только классы, но и теги и их вложенность.


    1. qravits Автор
      29.12.2018 11:01

      Такое, скорее всего, не встретится. Чтобы защитить сайт от парсинга, достаточно передавать «пустую» верстку, данные подставлять через javascript, который, конечно, обфусцировать, хотя, всякое может быть. Если энтропию и в DOM добавили, то уже не зацепишься.


      1. spy45242
        29.12.2018 12:54

        Ну даже, если данные подставлять через js, тому же PhantomJS как бы всё равно, через что данные подставляются. Не знаю, может быть мне так «везет», но я встречал ресурсы, где к примеру в теге «div» лежит часть контента, а после перезагрузки страницы эта часть лежит уже в теге «р» и у регулярки, которая работала с «div» на этом, как говориться, полномочия уже всё…


  1. iit
    29.12.2018 13:50

    А почему не DomCrawler?


    Как по мне — одна из самых шустрых и надежных библиотек а если еще использовать css-selector то парсинг становится даже приятным.


    1. riky
      30.12.2018 19:08
      +1

      использую и DomCrawler и phpQuery. второй есть сборка в одном файле — удобно когда надо спарсить чтото совсем маленькое по-быстрому, проинклюдил и поехал. композер конечно тоже не сложно, но на практике так быстрее.
      к тому же обе библиотеки обертки над DomDocument и разрешают прямой доступ к DomElement. да и главное там css селекторы которые плюс минус одинаковые.
      Если делаю более менее серьезный проект — то использую DomCrawler.


  1. RuCaptcha
    30.12.2018 19:27
    +1

    Добрый день.
    Зарегистрировались дабы указать на неточность при упоминании нашего сервиса.

    С капчей можно было бы посоревноваться при помощи ruCaptcha. Но только не в случае с reCaptcha. Бросьте эту идею, она провальна. Весь наш метод прямых HTTP-запросов из PHP уперся в тупик.

    Это не так.
    Мы успешно работаем по методу передачи токена от решённой ReCaptcha. Вам ну нужно эмулировать браузер, Вы продолжаете общаться с любым сайтом посредством HTTP-запросов.
    Всё дело в том, как работает ReCaptcha:
    у пользователя она появляется во фрейме, загружаясь с google.com. После решения рекапчи, пользователь получает текстовую строку (g-recaptcha-responce), эту текстовую строку браузер пользователя отправляет вместе с остальными данными формы сайту, на котором и установлена капча. Сайт обращается к google.com с проверкой валидности g-recaptcha-responce от пользователя. Google.com поддтверждает (или нет), что токен валидный.
    Фишка в том, что RuCaptcha умеет вызывать в софте работника рекапчу со всеми нужными параметрами, а потом отдаёт Вам, как клиенту, g-recaptcha-responce, который Вы в свою очередь используете при обращении к сайту. Таким образом обходится ReCaptcha (V2 и V3 тоже).
    На нашем сайте вы можете ознакомиться с более подробным объяснением (ссылку давать не буду, а то модератор порежет комментарий от свежего пользователя)


    1. qravits Автор
      30.12.2018 19:36
      +1

      Спасибо, что зарегистрировались здесь! Да, я видел, что у вас анонсирован обход reCaptcha v.3.
      Конечно, не стоит надеяться, что кто-то будет палить инсайд, но вкратце, было бы интересно узнать какой путь вы прошли, выстраивая архитектуру и обучая нейронную сеть.


  1. guyasyou
    30.12.2018 19:28
    +1

    Скажу по секрету — в циане есть JS, в которой все нужные данные, и даже больше, аккуратно упакованы в JSON. И это не единственный сайт, который защищается от парсинга в HTML и при этом выкладывает все в json.


  1. Rastishka
    30.12.2018 19:36
    +1

    Да, phpQuery, хоть и удобная штука, но память утекает.
    Неправда. Если очищать — ничего не утекает.
    Проверено на сотнях тысяч страниц.
    Вы документацию читали?


  1. a-tk
    30.12.2018 21:07

    Картинка в начале статьи вполне соответствует впечатлениям от беглого её просмотра