Пример парсинга и автоматизации получения информации

Тема в разделе ".:: Готовые решения", создана пользователем Darkmind, 6 ноя 2012.

Статус темы:
Закрыта.
  1. Darkmind

    Darkmind SNMP maniac

    Регистр.:
    31 май 2006
    Сообщения:
    182
    Симпатии:
    74
    Столкнулся с необходимостью автоматизации работы с веб-мордой базы телефонных номеров. В процессе решения задачи отловил несколько забавных моментов и, поскольку тема парсинга сайтов всегда была популярной, решил поделиться с сообществом в образовательных целях. Тема обучающая, взломом её тоже назвать нельзя. Плюс она исключительно специфическая и в "готовые решения" её тоже публиковать смысла нет. Поэтому я размещу её в общем форуме. На этом закончим с лирикой.

    Суть: есть сайт http://www.numuri.lv, представляющий собой сервис, выдающий название оператора и регистратора по запросу телефонного номера. Блоки номеров, конечно, распределены по операторам, но действующее законодательство страны позволяет переходить от одного оператора к другому, сохраняя номер телефона. При этом тарификация звонков между сетями разных операторов различается и может возникнуть необходимость проверки принадлежности номера к определенной сети. Конечно, имело бы смысл использовать родной веб-сервис, но никакого SLA этот сайт не предоставляет и гарантий актуальности данных не даёт. Да и задача на первый взгляд несложная - отправить POST и получить ответ.

    Посетитель видит текстовое поле и submit. Однако... Подключаемся Dragonfly (или Firebug'ом) и отправляем пробный пост. Оказывается, форма засылает куда большее количество полей, большая часть из которых пустые. Заглянем в исходник страницы и видим 20 текстовых полей для ввода номера, пара hidden полей, одно из которых содержит стрёмный 256-битный ключ (который меняется с каждым рефрешем), а другое - не менее стрёмный хэш.
    [​IMG]

    При этом при отправке формы введённый номер телефона отправляется в двух полях. Одно поле остаётся неизменным, а второе каждый раз разное. Можно сделать промежуточный вывод - второе поле является контрольным. Поскольку мы отправляем уже сгенерированную форму, то второе поле заполняется автоматически и алгоритм его получения находится в коде страницы. Ищем onsubmit или onclick и, разумеется, находим. Вызывается некая функция r(), которая в свою очередь вызывает соседнюю функцию D(). Переводим обе функции в удобочитаемый вид и вот он алгоритм получения ID'шника второго "контрольного" поля.
    [​IMG]

    Дело за малым - последовательно выполнить вполне понятные действия:
    1. Получаем стартовую страницу через CURL
      PHP:
      $options = array(
          
      CURLOPT_URL            => $config['init_url'],
          
      CURLOPT_HEADER         => true,
          
      CURLOPT_FRESH_CONNECT  => true,
          
      CURLOPT_RETURNTRANSFER => true,
          
      CURLOPT_USERAGENT      => 'Opera/9.80 (Windows NT 6.1; WOW64; U; en) Presto/2.10.289 Version/12.02',
      );
       
      $ch curl_init();
            
      curl_setopt_array($ch$options);
       
      $ce curl_exec$ch );
            
      curl_close$ch );
    2. Выцепляем из полученной страницы ключевые поля: хэш и 256-битный ключ
      PHP:
      preg_match('/<input type="hidden" name="__VIEWSTATE" id="__VIEWSTATE" value="(.+)"/'$ce$viewstate);
      preg_match('/<input name="nusa:keyField" id="nusa_keyField" type="hidden" value="(.+)"/'$ce$keyfield);
    3. Затем выцепляем функцию получения номера контрольного поля. Это делается для того, чтобы преобразовать ее на лету в PHP-функцию. Как оказалось - движок меняет алгоритм получения номера контрольного поля каждый день.
      PHP:
      preg_match('/(function D\(A\).+return h;\})/i'$ce$funcD);
    4. Преобразовываем функцию в PHP и делаем ей eval()
      PHP:
      $funcD str_replace(
          array(
      'D(A)''var ''A.length''A.charCodeAt(i)'),
          array(
      'D($A)''''strlen($A)'' ord($A{$i})'),
          
      preg_replace(
              
      '/(\s|\*|\{|;)(h|i)/i',
              
      '$1\$$2',
              
      $funcD[0]
          )
      );
       
      eval(
      $funcD);
    5. Вторая функция r() неизменна и её пока можно определить руками
      PHP:
      function r$keyfield$c )
      {
          if( !
      function_exists("D") ) {
              die(
      "Function D() is not defined");
          }
          return 
      D$keyfield ) % $c;
      }
    6. Судя по изменяемому алгоритму, я предположил, что с них станется и количество текстовых полей поменять, поэтому скрипт определяет их количество. Заодно при анализе исходного кода стало понятно, что реально видимое поле может изменяться и его тоже можно определить. Одним выстрелом двух зайцев:
      PHP:
      preg_match('/<input\s+name.+type="submit".+onclick="r\( document\.getElementById\(\''.$this->txtBoxId.'(\d+)\'\),.+,.+,(\d+)\)/i'$ce$matches);
    7. Теперь можно вычислять какое поле из 19 "фальшивых" будет контрольным
      PHP:
      $controlField r($keyfield[1], $txtBoxes[1]);
    8. Формируем массив на отправку. Для отправки POST'а будет достаточно обычного ассоциативного массива. Код приводить не буду - простой обход по полученному количеству полей и добавление искомого номера телефона к двум нужным полям (вычислено выше)
    9. Отправляем страницу точно так же через CURL
      PHP:
      $options = array(
          
      CURLOPT_URL            => 'http://www.numuri.lv/default.aspx',
          
      CURLOPT_HEADER         => true,
          
      CURLOPT_RETURNTRANSFER => true,
          
      CURLOPT_POST           => true,
          
      CURLOPT_POSTFIELDS     => $data,
          
      CURLOPT_REFERER        => 'http://www.numuri.lv/default.aspx',
          
      CURLOPT_USERAGENT      => 'Opera/9.80 (Windows NT 6.1; WOW64; U; en) Presto/2.10.289 Version/12.02',
      );
       
      $ch curl_init();
            
      curl_setopt_array($ch$options);
       
      $ce curl_exec$ch );
            
      curl_close$ch );
    10. Вуаля, мы получили данные.
    Эпопея на этом не заканчивается - полученные данные слегка обфусцированы. Данные приходят в виде мешанины символов и HTML-сущностей, причём у некоторых сущностей отсутствует точка с запятой.
    [​IMG]

    Откровенная эксплуатация недокументированных особенностей бразуеров - они дополняют пропущенные точки с запятой и символ всё равно выводится в читаемом формате, однако html_entity_decode() давится и не может его распознать. Чиним, последовательно применяя:
    PHP:
    $element trim(strip_tags$element ));
    $element html_entity_decodepreg_replace'/(&#[\d]+)/i''$1;'$element ) , ENT_NOQUOTES'UTF-8');
    Последний штрих - превращение последовательного скрипта в веб сервис. Я для этого привёл код к объектному виду, заменил глупые die() на исключения и использовал PHP SOAP. Скучно, тривиально и любовь к SOAP и REST выходит за рамки данной темы.

    Зачем я раскатал всё это? Просто в качестве туториала по подходу к парсингу каких-то данных. Такого рода задачи решаются несложно при последовательном подходе. Напоследок, в виде заключения, несколько советов:
    • Изучите возможности CURL. Принимайте куки если есть необходимость (CURLOPT_COOKIEJAR, CURLOPT_COOKIEFILE). Маскируйтесь под нормальные браузеры (CURLOPT_USERAGENT). Читайте получаемые заголовки, работайте с SSL и авторизацией.
    • Для парсинга иногда имеет смысл читать полученную страницу, как DOM. Будьте внимательны - ресурсы съедаются на ура, но большие объёмы данных становится обрабатывать удобнее.
    • Для небольших задач DOM использовать не нужно - не стреляйте из пушки по воробьям; вполне можно обойтись регулярными выражениями.
    • Изучите SOAP. Некоторые сайты предоставляют точки входа в виде вебсервисов - это избавит вас от необходимости от возни с парсингом HTML.
     
    bob, StrikeOFF, grenadine и 4 другим нравится это.
  2. Darkmind

    Darkmind SNMP maniac

    Регистр.:
    31 май 2006
    Сообщения:
    182
    Симпатии:
    74
    Поскольку модераторы перенесли топик в "Готовые решения", пошарю полностью итоговую версию кода.
    Для удобства выложу его на гитхаб: https://github.com/Haran/Numuri-Parser

    P.S. Приношу свои извинения за даблпостинг, но раз это обучалка, то счёл что апдейт лучше выложить отдельно.
     
Статус темы:
Закрыта.