Search
Write a publication
Pull to refresh

RSS из любого сайта средствами PHP

Здравствуйте, хабра-сообщество! В своём первом посте хотелось бы затронуть тему сбора данных с любого сайта, и формирования из них лент RSS.

Предисловие

Когда в нашем городе только провели интернет, и домашний компьютер начал постигать просторы всемирной паутины, в этой паутине начали появляться сайты, которые хочется посещать многократно, чтобы быть в курсе каких то новостей/новинок. Список избранного в браузере стал потихоньку пополняться ссылками на разнообразные ресурсы. Как только появлялась свободная минутка, я быстренько пробегался по этим сайтам и смотрел что там нового. Пока список этот был небольшим, это не вызывало особой сложности. Но время шло, список рос, пробегаться стало сложно, тогда я для себя открыл RSS, который до этого казался мне чем то непонятным/ненужным.
Так началась у меня эпоха использования RSS-клиента (Mozilla Thunderbird). Но и с помощью этого способа остались некоторые неудобства, а точнее появилась проблема синхронизации рабочего и домашнего компьютера: то что прочитал на работе не будет отмечено как прочитанное дома. На помощь пришёл великий Google Reader, мир стал прекраснее, чудеснее и лучше. Но, за исключением одного большого «НО». Есть единичные, но очень нужные сайты, новости с которых очень хочется почитать в Google Reader, но RSS в них разработчики не предусмотрели…

Варианты

Я стал искать решения, для того чтобы побороть эту проблему. И они находились: Feed43, feedfire.com, но по тем или иным соображениям они мне не подходили. Уж больно ленты специфичные мне потребовались.

Задача

Выдернуть новости с этого сайта и с этого форума (на момент, когда я писал этот код, на форуме не было RSS-подписки на темы).
Сложность первого сайта в том, что там нет семантики, и какого то отделения новостей. Новости — на нём текст сплошняком. А второго как раз наоборот, в сложности и излишних данных. Я расчехлил шашечки и ринулся писать код.

Решение

Я решил совместить два способа для поиска и отделения новостей:
  1. использование CSS-синтаксиса для поиска элементов
  2. использование регулярных выражений для дополнительной фильтрации

Вот что собственно из этого вышло (просьба сильно не придираться, код был написан очень быстро, буквально на коленке):

Класс 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 (хотя так более понятнее).

Всем спасибо за внимание!
Tags:
Hubs:
You can’t comment this publication because its author is not yet a full member of the community. You will be able to contact the author only after he or she has been invited by someone in the community. Until then, author’s username will be hidden by an alias.