Решение было найдено довольно быстро и им стало использоваться Tidy + XML DOM + SimpleXML. Почему именно такая связка спросите вы? Объясню необходимость каждого используемого компонента.
Tidy
Качество разметки некоторых сайтов оставляет желать лучшего. Кроме того многие сайты до сих пор используют HTML а не более новый XHTML. XML DOM может принимать на вход только XML или XHTML. Решением этих проблем становится Tidy который может получать данные в любом формате, преобразовывать их в XHTML, изменять их кодировку (если необходимо).
XML DOM
Его использование необходимо т.к. SimpleXML может принимать на вход только XML и XML DOM. Нам он нужен именно из-за второй возможности – строить объект по XML DOM
SimpleXML
Эта библиотека помогает сэкономить кучу времени при обработке XML. В принципе можно ее вообще не использовать в этой связке, но это сильно усложнит операции с данными.
Пример скрипта
Данный скрипт я сделал упрощением скрипта для парсинга информации с yandex market
class Grabber {
static private function rawToSimpleXML($data){
/*
* Конциг Tidy
*/
$tidy_config = array(
'input-encoding' => 'utf8',
'output-encoding' => 'utf8',
'output-xml' => TRUE,
'add-xml-decl' => TRUE,
'hide-comments' => TRUE
);
$tidy_encoding = 'utf8';
/*
* Загрузка данных и очиска от ошибок
*/
$tidy = tidy_parse_string($data, $tidy_config, $tidy_config['output-encoding']);
$tidy->CleanRepair();
$tidy_out = $tidy->html()->value;
/*
* Инициализация XML DOM
*/
$dom = new DOMDocument();
$dom->strictErrorChecking = FALSE;
@$dom->loadHTML($tidy_out);
unset($tidy);
/*
* Инициализация SimpleXML
*/
$simpexml = simplexml_import_dom($dom);
unset($dom);
return $simpexml;
}
/*
* Вспомогательная функция для очистки XML
*/
static private function xmlEscape($text){
$text = str_replace("\r", '', $text);
$text = str_replace("\n", ' ', $text);
return $text;
}
/*
* Вспомогательная функция для получения данных из SimpleXML
*/
static private function getFromXPath($xml){
$text = '';
if($xml && $xml[0]){
$text = self::xmlEscape( html_entity_decode (strip_tags($xml[0]->asXML())));
}
return $text;
}
static public function parseItemsPage($url){
// Подготовка возвращаемого массива
$ret = array('current' => $url);
// Получение данных
$input = file_get_contents($url);
// Подготовка полученных данных к обработке
$xml = self::rawToSimpleXML($input);
// Хлебные крошки
$ret['category_name'] = self::getFromXPath($xml->xpath('/html/body/table[2]/tbody/tr[3]/td/div/div'));
// Товары
$ret['goods'] = array();
$goods = $xml->xpath('/html/body/table[2]/tbody/tr[3]/td/table/tbody/tr');
foreach ($goods as $item){
$ret['goods'][] = array(
'name' => strip_tags($item->td[2]->div->a->asXML()),
'href' => 'http://market.yandex.ru' . (string) $item->td[2]->div->a['href']
);
}
// --- Ссылки
$next_link = $xml->xpath('//*[@id="pager-next"]/@href');
$prev_link = $xml->xpath('//*[@id="pager-prev"]/@href');
$ret['next'] = $next_link ? 'http://market.yandex.ru' . $next_link[0] : '';
$ret['prev'] = $prev_link ? 'http://market.yandex.ru' . $prev_link[0] : '';
return $ret;
}
}
/*
* Необходимые методы дописываем в класс и используем
*/
$data = Grabber::parseItemsPage('http://...');
Преимущества данного подхода
Мне требовалось парсить очень много сайтов, поэтому время уходившее на написание парсера под определенный сайт было очень важно. Использование моего способа было чрезвычайно быстрым т.к. в FireFox имеется замечательное дополнение под названием FireBug, которое позволяет всего парой кликов получить XPath нужного элемента.
Возможная модернизация
В некоторых случаях может потребоваться использовать curl, который умеет сохранять cookie при переходе от одной страницы к другой и подменять заголовки.
Скрипт для парсинга данных с сайта МосЭкоМониторинга
Для примера приведу еще один скрипт работающий по подобной схеме. Он предназначен для парсинга данных с сайта мосэкомониторинга. В нем используется Doctrine ORM. Я не стал вырезать эти участки т.к. скрипт приводится в ознакомительных целях и кому нужно тот сам подправит как ему надо.
Пример ссылок подлежащих парсингу:
http://www.mosecom.ru/air/air-today/station/veshnyaki/2_28.html.t1
Полный список станций
class mdEcomonParser {
private $ch;
private $url;
private $date;
private static $nc = array('CO', 'NO', 'NO2');
private static $ncp = array();
private function curlConfigure($curl) {
/*
* Фэйковые заголовки
*/
$headers = array(
'Accept=text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language=ru,en-us;q=0.7,en;q=0.3',
'Accept-Charset=windows-1251,utf-8;q=0.7,*;q=0.7',
'Keep-Alive=300',
'Connection=keep-alive',
'Cache-Control=max-age=0'
);
curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
curl_setopt($curl, CURLOPT_USERAGENT, 'User-Agent=Mozilla/5.0 (Windows; U; Windows NT 5.1; ru; rv:1.9.0.11) Gecko/2009060215 Firefox/3.0.11');
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($curl, CURLOPT_ENCODING, 'gzip,deflate');
curl_setopt($curl, CURLOPT_TIMEOUT, 10);
/*
* Если необходима поддержка cookie (и сессий)
*/
//curl_setopt($this->ch, CURLOPT_COOKIEJAR, 'path\to\cookies.dat');
//curl_setopt($this->ch, CURLOPT_COOKIEFILE, 'path\to\cookies.dat');
/*
* Не следовать редиректам
*/
curl_setopt($curl, CURLOPT_FOLLOWLOCATION, 0);
}
private function curlGet($url) {
/*
* Инициализируем Curl
*/
$curl = curl_init();
$this->curlConfigure($curl);
/*
* установка URL и других необходимых параметров
*/
curl_setopt($curl, CURLOPT_URL, $url);
/*
* Получение данных
*/
$html = curl_exec($curl);
$info = curl_getinfo($curl);
/*
* В код нужно добавить обработчик изсключений если есть такая необходимость
*/
if ($html === false || $info['http_code'] != 200) {
throw new Exception('Curl: ' . $curl_msg);
}
/*
* Чистим память и отдаем результат
*/
curl_close($curl);
return $html;
}
static private function rawToSimpleXML($data) {
/*
* Конфиг tidy
*/
$tidy_config = array(
'input-encoding' => 'utf8',
'output-encoding' => 'utf8',
'output-xml' => TRUE,
'add-xml-decl' => TRUE,
'hide-comments' => TRUE
);
/*
* Загрузка и очистка данных при помощи tidy
*/
$tidy = tidy_parse_string($data, $tidy_config, $tidy_config['output-encoding']);
$tidy->CleanRepair();
$tidy_out = $tidy->html()->value;
/*
* XML DOM
*/
$dom = new DOMDocument();
$dom->strictErrorChecking = FALSE;
$dom->loadHTML($tidy_out);
unset($tidy);
/*
* SimpleXML
*/
$xml = simplexml_import_dom($dom);
unset($dom);
return $xml;
}
/*
* Очистка входных данных
*/
private function clearData($data) {
$data = strip_tags($data);
$data = html_entity_decode($data);
$data = str_replace("\r", '', $data);
$data = str_replace("\n", '', $data);
$data = trim($data);
return $data;
}
/*
* Обработка числовых полей
*/
private function clearNumber($number) {
$number = $this->clearData($number);
if(!is_numeric($number)) {
$number = null;
}
return $number;
}
/*
* Обработка дат
*/
private function loadDate($date) {
$datetime = preg_match('@^(\d{2}\.\d{2}\.\d{2})\s(\d{2}\:\d{2})$@i', $date, $matches);
if($datetime > 0) {
$this->date = $matches[1];
}else {
if($date == '00:00'){
$date_parts = explode('.', $this->date);
$next_day = mktime (0, 0, 0, $date_parts[1], $date_parts[0], '20' . $date_parts[2]) + 60*60*24;
$this->date = date('d.m.y', $next_day);
}
$date = $this->date . ' ' . $date;
}
return $date;
}
/*
* Парсим таблицу с сайта
*/
public function parseTable($url) {
$data = $this->curlGet($url);
$xml = $this->rawToSimpleXML($data);
$table_data = $xml->xpath('/html/body/table/tr/td/div/table/tr');
$table_header = array_shift($table_data);
/*
* Поиск номеров столбцов
*/
for($i=0;$ith);$j++) {
$col = $this->clearData($table_header->th[$j]);
//trace(' - variant ' . $col);
if($col == $needed_col){
self::$ncp[$i] = $j;
}
}
}
$lines = array();
foreach($table_data as $item) {
$lines[] = array(
'date' => $this->loadDate($this->clearData($item->td[0]->div->asXml())),
'co' => $this->clearNumber($item->td[self::$ncp[0]]->div->asXml()),
'no' => $this->clearNumber($item->td[self::$ncp[1]]->div->asXml()),
'no2' => $this->clearNumber($item->td[self::$ncp[2]]->div->asXml()),
);
}
return $lines;
}
}
/*
* Использование класса
*/
/*
* Получаем список станций экомониторинга
*
* В таблицах МосЭкоМониторинга проблемы с датами - они не всегда проставляются, поэтому с ними дополнительная волокита
*
*/
$stations = Doctrine::getTable('EcomonStation')->findAll();
foreach($stations as $station) {
$parser = new mdEcomonParser();
$lines = $parser->parseTable($station->getTableUrl());
$station_id = $station->getId();
$station_name = $station->getAlias();
foreach ($lines as $line) {
$result = preg_match('@^(\d{2})\.(\d{2})\.(\d{2})\s(\d{2})\:(\d{2})$@i', $line['date'], $matches);
if($result > 0) {
$line['date'] = $matches[3] . '-' . $matches[2] . '-' . $matches[1] . ' ' . $matches[4] . ':' . $matches[5] . ':00';
/*
* Ищем запись в базе данных
*/
$count = Doctrine_Query::create()
->from('Ecomon')
->where('station_id = ?', $station_id)
->andWhere('date = ?', $line['date'])
->count();
if($count == 0) {
/*
* Добавляем запись
*/
$ecomon = new Ecomon();
$ecomon->setStationId($station_id);
$ecomon->setDate($line['date']);
$ecomon->setValCo($line['co']);
$ecomon->setValNo($line['no']);
$ecomon->setValNo2($line['no2']);
$ecomon->save();
$ecomon->free();
}else {
trace('Item exists ('.$station_name.': '.$line['date'].')');
}
}
}
}
