Здравствуйте, хабра-сообщество! В своём первом посте хотелось бы затронуть тему сбора данных с любого сайта, и формирования из них лент RSS.
Когда в нашем городе только провели интернет, и домашний компьютер начал постигать просторы всемирной паутины, в этой паутине начали появляться сайты, которые хочется посещать многократно, чтобы быть в курсе каких то новостей/новинок. Список избранного в браузере стал потихоньку пополняться ссылками на разнообразные ресурсы. Как только появлялась свободная минутка, я быстренько пробегался по этим сайтам и смотрел что там нового. Пока список этот был небольшим, это не вызывало особой сложности. Но время шло, список рос, пробегаться стало сложно, тогда я для себя открыл RSS, который до этого казался мне чем то непонятным/ненужным.
Так началась у меня эпоха использования RSS-клиента (Mozilla Thunderbird). Но и с помощью этого способа остались некоторые неудобства, а точнее появилась проблема синхронизации рабочего и домашнего компьютера: то что прочитал на работе не будет отмечено как прочитанное дома. На помощь пришёл великий Google Reader, мир стал прекраснее, чудеснее и лучше. Но, за исключением одного большого «НО». Есть единичные, но очень нужные сайты, новости с которых очень хочется почитать в Google Reader, но RSS в них разработчики не предусмотрели…
Я стал искать решения, для того чтобы побороть эту проблему. И они находились: Feed43, feedfire.com, но по тем или иным соображениям они мне не подходили. Уж больно ленты специфичные мне потребовались.
Выдернуть новости с этого сайта и с этого форума (на момент, когда я писал этот код, на форуме не было RSS-подписки на темы).
Сложность первого сайта в том, что там нет семантики, и какого то отделения новостей. Новости — на нём текст сплошняком. А второго как раз наоборот, в сложности и излишних данных. Я расчехлил шашечки и ринулся писать код.
Я решил совместить два способа для поиска и отделения новостей:
Вот что собственно из этого вышло (просьба сильно не придираться, код был написан очень быстро, буквально на коленке):
Класс CHTML2RSS (файл CHTML2RSS.class.php):
Использование (файл html2rss.php):
В файле html2rss.php пример использования, для ресурсов которые я привёл. Создаётся экземпляр класса CHTML2RSS с необходимыми параметрами, где:
url — адрес страницы для парсинга
charset — кодировка страницы, если не UTF-8
elems — массив из CSS-запроса и регулярного выражения для отделения новостей
remove — какие элементы нужно удалить из новостей
search — здесь можно осуществить поиск дополнительных параметров, для выставления заголовков/ссылок на новость/id новости
reverse — перевернуть порядок записей на обратный
rtitle — заголовок ленты
rdesc — описание ленты
rlink — ссылка на сайт ленты
ititle — шаблон заголовка записи, здесь можно применять переменные которые мы нашли с использованием параметра search, вывод происходит с помощью функции PHP sprintf, в которую передаётся массив найденных значений, которые мы ищем с помоoью параметра search
idesc — текст записи, аналогично с помощью sprintf
ilink — ссылка на запись
idate — дата записи
guid — уникальный идентификатор записи
При разработке мне пришлось столкнуться с одним багом в функции php loadHTML, суть в том что мета-тэг <meta http-equiv=«Content-Type»...> с заданием нужной кодировки должен быть указан ДО того, как в тексте встретится любой текст. Из-за этой проблемы не парсилась страница из news.metro.ru, т. к. тэг <title> был до тэга <meta>.
Так же можно в коде не реализовано, хотя было бы лучше, если применять нативный xpath, нежели реализацию фильтрации CSS (хотя так более понятнее).
Всем спасибо за внимание!
Предисловие
Когда в нашем городе только провели интернет, и домашний компьютер начал постигать просторы всемирной паутины, в этой паутине начали появляться сайты, которые хочется посещать многократно, чтобы быть в курсе каких то новостей/новинок. Список избранного в браузере стал потихоньку пополняться ссылками на разнообразные ресурсы. Как только появлялась свободная минутка, я быстренько пробегался по этим сайтам и смотрел что там нового. Пока список этот был небольшим, это не вызывало особой сложности. Но время шло, список рос, пробегаться стало сложно, тогда я для себя открыл RSS, который до этого казался мне чем то непонятным/ненужным.
Так началась у меня эпоха использования RSS-клиента (Mozilla Thunderbird). Но и с помощью этого способа остались некоторые неудобства, а точнее появилась проблема синхронизации рабочего и домашнего компьютера: то что прочитал на работе не будет отмечено как прочитанное дома. На помощь пришёл великий Google Reader, мир стал прекраснее, чудеснее и лучше. Но, за исключением одного большого «НО». Есть единичные, но очень нужные сайты, новости с которых очень хочется почитать в Google Reader, но RSS в них разработчики не предусмотрели…
Варианты
Я стал искать решения, для того чтобы побороть эту проблему. И они находились: Feed43, feedfire.com, но по тем или иным соображениям они мне не подходили. Уж больно ленты специфичные мне потребовались.
Задача
Выдернуть новости с этого сайта и с этого форума (на момент, когда я писал этот код, на форуме не было RSS-подписки на темы).
Сложность первого сайта в том, что там нет семантики, и какого то отделения новостей. Новости — на нём текст сплошняком. А второго как раз наоборот, в сложности и излишних данных. Я расчехлил шашечки и ринулся писать код.
Решение
Я решил совместить два способа для поиска и отделения новостей:
- использование CSS-синтаксиса для поиска элементов
- использование регулярных выражений для дополнительной фильтрации
Вот что собственно из этого вышло (просьба сильно не придираться, код был написан очень быстро, буквально на коленке):
Класс CHTML2RSS (файл CHTML2RSS.class.php):
<?php
class CHTML2RSS
{
private $dom = false;
public $params;
public function __construct($params)
{
$this->params = $params;
}
// загружаем страницу и немного обрабатываем
function getDocument($url)
{
$cnt = $this->params['charset'] ? '<meta http-equiv="Content-Type" content="text/html; charset='.$this->params['charset'].'">' : '';
$cnt.=file_get_contents($url);
$cnt = preg_replace('/(?s)<script.*?<\/script>/', '', $cnt); // bug with CDATA
$this->dom = new DomDocument();
@$this->dom->loadHTML($cnt);
}
// callback для обхода DOM
function domWalk($root, $callback, &$args)
{
if($root->childNodes)
foreach($root->childNodes as $i => $elem)
{
call_user_func_array($callback, array_merge(array($elem), $args));
$this->domWalk($elem, $callback, $args);
}
}
// callback для фильтрации в стиле CSS
function walkCallback($elem, &$attr, $result, $idx)
{
$add=true; $filter = true;
foreach($attr as $sel)
{
if(!$add) break;
switch($sel[1])
{
case '':
if($elem->nodeName!=$sel[2]) $add=false;
break;
case '.':
if(!$elem->attributes){ $add = false; break; }
$node=$elem->attributes->getNamedItem('class');
if(!$node || $node->nodeValue != $sel[2]) $add=false;
break;
case '#':
if(!$elem->attributes){ $add = false; break; }
$node=$elem->attributes->getNamedItem('id');
if(!$node || $node->nodeValue != $sel[2]) $add=false;
break;
case ':':
switch($sel[2])
{
case 'eq': if($idx!=$sel[3]) $filter = false; break;
case 'lt': if($idx>=$sel[3]) $filter = false; break;
case 'gt': if($idx<=$sel[3]) $filter = false; break;
}
break;
}
}
if($add) $idx++;
if($add && $filter) $result[]=$elem;
}
function parseDom($selector, $parent = false)
{
$result=array();
$arr=explode(' ',$selector);
$root = $parent ? $parent : array($this->dom);
foreach($arr as $item)
{
preg_match_all('/(^|\.|#|:)(\w+)(?:\((\d+)\))*/', $item, $attr, PREG_SET_ORDER);
$newRoot=array();
$idx = 0;
$attr=array($attr, &$newRoot, &$idx);
foreach($root as $elem) $this->domWalk($elem, array($this, 'walkCallback'), $attr);
$root=$newRoot;
}
return $root;
}
// получение outerHTML для элемента
function outerHTML($element)
{
$d = new DomDocument();
$d->appendChild($d->importNode($element, true));
return html_entity_decode($d->saveHTML(), ENT_COMPAT, 'UTF-8');
}
// получение innerHTML для элемента
function innerHTML($element)
{
$d = new DomDocument();
foreach($element->childNodes as $node)
$d->appendChild($d->importNode($node, true));
return html_entity_decode($d->saveHTML(), ENT_COMPAT, 'UTF-8');
}
// основная функция парсинга
function parseHTML()
{
$this->getDocument($this->params['url']);
$v = array();
$elems = $this->parseDom($this->params['elems'][0]);
if($this->params['elems'][1])
foreach($elems as $elem)
{
preg_match_all($this->params['elems'][1], $this->outerHTML($elem), $vi, PREG_SET_ORDER);
$v = array_merge($v, $vi);
}
else
{
// удаляем
if($this->params['remove'])
foreach($this->params['remove'] as $sel)
{
$delElems = $this->parseDom($sel, $elems);
foreach($delElems as $elem)
$elem->parentNode->removeChild($elem);
}
// ищем совпадения
foreach($elems as $i => $elem)
{
$v[$i] = array();
foreach($this->params['search'] as $srch)
{
if($srch[0])
{
$ei = $this->parseDom($srch[0], array($elem));
if(!count($ei)) continue;
$ei = $ei[0];
}
else $ei = $elem;
if($srch[1])
{
preg_match($srch[1], $this->outerHTML($ei), $vi);
$v[$i] = array_merge($v[$i], $vi);
}
else $v[$i][] = $this->innerHTML($ei);
}
}
}
return $v;
}
// вывод RSS
function showRSS()
{
$v = $this->parseHTML();
echo '<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>'.htmlspecialchars($this->params['rtitle']).'</title>
<description>'.htmlspecialchars($this->params['rdesc']).'</description>
<link>'.htmlspecialchars($this->params['rlink']).'</link>';
if($this->params['reverse']) $v = array_reverse($v);
foreach($v as $item)
{
echo " <item>\n";
echo " <title>".htmlspecialchars(strip_tags(vsprintf($this->params['ititle'], $item)))."</title>\n";
echo " <link>".htmlspecialchars(vsprintf($this->params['ilink'], $item))."</link>\n";
echo " <description><![CDATA[".vsprintf($this->params['idesc'], $item)."]]></description>\n";
if($this->params['idate']) echo " <pubDate>".htmlspecialchars(vsprintf($this->params['idate'], $item))."</pubDate>\n";
if($this->params['guid']) echo " <guid>".htmlspecialchars(vsprintf($this->params['guid'], $item))."</guid>\n";
echo " </item>\n";
}
echo <<<END
</channel>
</rss>
END;
}
}
?>
Использование (файл html2rss.php):
<?php
require_once('CHTML2RSS.class.php');
$config = array(
'metro' => array
(
'url' => 'http://news.metro.ru/',
'charset' => 'windows-1251',
'elems' => array('tr#mnews td', '/<p><strong>([^<>]*)<\/strong>(.+)<\/p>/'),
'search' => false,
'reverse' => false,
'rtitle' => 'news.metro.ru',
'rdesc' => 'Moscow Subway - Metro de Moscou - Москва, метрополитен',
'rlink' => 'http://news.metro.ru/',
'ititle' => 'новость от %2$s',
'idesc' => '%3$s',
'ilink' => 'http://news.metro.ru/',
'idate' => false,
'guid' => false
),
'4pda' => array
(
'url' => 'http://4pda.ru/forum/index.php?showtopic=116079&st=9999',
'charset' => false,
'elems' => array('.borderwrap .ipbtable:gt(0)', false),
'remove' => array('span.edit'),
'search' => array
(
array('.postdetails a', false),
array('.postdetails a', '/href="([^"]+)"/'),
array('.postcolor', false)
),
'reverse' => true,
'rtitle' => '4pda.ru - HTC MAX 4G - обсуждение',
'rdesc' => '4pda.ru - HTC MAX 4G - обсуждение',
'rlink' => 'http://4pda.ru/forum/index.php?showtopic=116079&st=9999',
'ititle' => 'пост %1$s',
'idesc' => '%4$s',
'ilink' => '%3$s',
'idate' => false,
'guid' => false
)
);
header('Content-Type: text/xml; charset=utf-8');
if(!isset($_GET['source'])) exit('Укажите источник новостей!');
if(!isset($config[$_GET['source']])) exit('Источника не существует!');
//file_put_contents(dirname(__FILE__).'/update.log', date('Y-m-d h:i:s')." {$_GET['source']}\r\n", FILE_APPEND);
$h2r = new CHTML2RSS($config[$_GET['source']]);
$h2r->showRSS();
?>
Использование
В файле html2rss.php пример использования, для ресурсов которые я привёл. Создаётся экземпляр класса CHTML2RSS с необходимыми параметрами, где:
url — адрес страницы для парсинга
charset — кодировка страницы, если не UTF-8
elems — массив из CSS-запроса и регулярного выражения для отделения новостей
remove — какие элементы нужно удалить из новостей
search — здесь можно осуществить поиск дополнительных параметров, для выставления заголовков/ссылок на новость/id новости
reverse — перевернуть порядок записей на обратный
rtitle — заголовок ленты
rdesc — описание ленты
rlink — ссылка на сайт ленты
ititle — шаблон заголовка записи, здесь можно применять переменные которые мы нашли с использованием параметра search, вывод происходит с помощью функции PHP sprintf, в которую передаётся массив найденных значений, которые мы ищем с помоoью параметра search
idesc — текст записи, аналогично с помощью sprintf
ilink — ссылка на запись
idate — дата записи
guid — уникальный идентификатор записи
Недостатки и сложности
При разработке мне пришлось столкнуться с одним багом в функции php loadHTML, суть в том что мета-тэг <meta http-equiv=«Content-Type»...> с заданием нужной кодировки должен быть указан ДО того, как в тексте встретится любой текст. Из-за этой проблемы не парсилась страница из news.metro.ru, т. к. тэг <title> был до тэга <meta>.
Так же можно в коде не реализовано, хотя было бы лучше, если применять нативный xpath, нежели реализацию фильтрации CSS (хотя так более понятнее).
Всем спасибо за внимание!