Главная > Data Mining, Open Source, PHP, web2.0, Разное > Получить RSS-ленты из страницы? Так точно!

Получить RSS-ленты из страницы? Так точно!

2 января 2010

Billboard_Feed_256x256Приветствую своих читателей! С Новым Годом! Первой темой, который мы откроем этот год жизни блога, будет следующая задача. Дано - система, схожая с Google Readers, которая принимает от пользователя некоторый адрес и должна обеспечить просмотр (а позже, и подписку) доступных там RSS-фидов. Задача осложняется тем, что от пользователя нельзя требовать ввода именно полного адреса ленты, да и даже просто адреса сайта или произвольной страницы - он может быть введен в совершенно разных вариантах, полностью или частично и т.п. Самих лент на странице также может быть более одно, часто нескольких форматов сразу (а то и не быть вовсе). Поэтому нам надо выбрать из всех доступных лент последние сообщения и отобразить пользователю, чтобы именно он выбрал в конечном итоге одну ленту, которая его интересует. Открою секрет - да, это только начало и в последующих статьях мы вместе постоим несколько уменьшенную версию системы агрегации и чтениях новостей. Но сегодня попробуем решить первую задачу, без которой наша "читалка" просто не сможет работать, какие бы дальше технологии не применялись.

Основой будет мой любимый инструмент  - Zend Framework (используем последнюю, trunk версию). Если вы знакомы с его возможностями, что сходу предложите компонент Zend_Feed, который имеет встроенные возможности по извлечению из страницы лент. Однако не спешите, на практике задача не так и проста. Поэтому будем решать ее постепенно.


Нормализация URL.

Пользователь вводит некоторый адрес, с которого нам предстоит извлечь все доступные ленты. Первым барьером будет то, что стандартный компонент (тот самый Zend_Feed) умеет работать только с полными адресами страниц (или правильной ссылкой на корень сайта). Не компонент вообще, а именно механизм нахождения лент. То есть, если мы хотим использовать автоматическое определение лент, нам необходимо передать ему полный адрес страницы и не больше. Если ссылка уже является прямым линком на ленту, как ни странно, в результате мы получим.. ничего не получим. То же самое будет, если мы введем, например, адрес сайта таким образом - www.abrdev.com или abrdev.com, вместо полного URL с указанием протокола - http://abrdev.com. Поэтому, самым первым шагом будет банальная проверка, начинается ли наша строка с указания протокола - "http://" или "https://". Текущая реализация компонент от Zend-а умеет работать только с этими протоколами. Кроме этого, существует ограничение при работе с лентами, которые требуют авторизации для доступа . В принципе, если там используется простая HTTP-авторизация, это вполне решаемо, но если требуется что-то другое, компоненты уже бессильны, поэтому мы сможем работать только с публично доступными лентами.

И так, нам необходима функция, которая принимает на входе произвольную строку, предположительно с адресом сайта или фида, а возвращает всегда или false, если строка ну никак не похожа на URL, или полный адрес, с указанием протокола и т.п. Для валидации мы используем другой компонент фреймворка - Zend_Uri, который предоставляет нам несколько инструментов для обработки и проверки URI (Uniform Resource Identifiers).

Сначала мы положимся на пользователя, поэтому попробуем сразу использовать переданную строку как адрес. Если это не получится и Zend_Uri откажется признать это правильным адресом, он выбросит исключение (или вернет false, если адрес просто неправильный), которые мы перехватим и уже попробуем привести к более правильной форме. Если же и вторая попытка не удается, тогда все, сдаемся и возвращаем false, означающее, что введенное пользователем никак не является корректным адресом расположения ленты.

  1. /**
  2. * Проверяет переданную строку как URI
  3. *
  4. * @param String $uri
  5. * @return boolean|string результат проверки или адрес
  6. * @throw Zend_Uri_Exception
  7. */
  8. public static function _validURI($uri)
  9. {
  10.   if (empty($uri)) return false;
  11.   else $uri = trim(strtolower(uri));
  12.         
  13.   try
  14.   {        
  15.      //проверим переданный URI
  16.      $_uri = Zend_Uri::factory($uri);
  17.     
  18.      $res = $_uri->valid($uri);
  19.             
  20.      if ($res === true)
  21.         {
  22.         //если все ок, возвращается валидированный URL
  23.           return $_uri->getUri();
  24.      }
  25.      else
  26.          return false;
  27.     }
  28.     catch (Zend_Uri_Exception $e)
  29.     {
  30.      //инвалид схема?
  31.      try
  32.      {
  33.          if (
  34.                  (strpos($uri, 'http://') === false) ||
  35.                  (strpos($uri, 'https://') === false)
  36.               )
  37.          {
  38.          $uri = self::$defailt_rss_scheme . $uri;
  39.                     
  40.          $_uri = Zend_Uri::factory($uri);
  41.         
  42.          if ($_uri->valid($uri)) return $_uri->getUri();
  43.          }
  44.             else
  45.                 //и схема есть, и все равно не валидный?
  46.                 return false;
  47.         }
  48.         catch (Zend_Uri_Exception $e)
  49.         {
  50.             return false;
  51.         }
  52.     }
  53. }    

* This source code was highlighted with Source Code Highlighter.

И так, первая задача решена - мы можем передавать любого вида адрес, а в результате получим или false, а значит ошибку, или строку с полным URL, который пригодный для дальнейшей обработки. Учтите, что доступны только URL с протоколами http/https, к тому же, только публичные (это не проверяется на этом этапе, поэтому, видимо, следует предупреждать пользователя в интерфейсе ввода адреса, так как у нас уже были случаи, когда пользователи вводили адреса лент, доступных только после авторизации, а при попытке обратиться к такому ресурсу сервер получал просто дефолтную страницу авторизации).

Получение прямых линков на ленты.

Теперь следующий этап - нам необходимо получить прямые ссылки на все ленты, которые сможем найти по указанному пользователем адресу. Всегда помним, что там может просто не оказаться ни одной ленты, а может быть несколько, как разных, так и одинаковых, просто различного формата. Вот чего-чего, а различных форматов и спецификаций лента набралось достаточно можно, к тому же часто бывают вообще уникальные и сложнейшие ленты (пока самой трудной оказалась лента от CNBC, в принципе, которая и послужила основой для переписывания старой системы обработки лент). Наше счастье, что разработчики Zend-а уже позаботились о том, чтобы компоненты имели полностью независимые интерфейсы и разработчики абстрагировались от всех нюансов спецификаций.

И так, на этом этапе у нас может быть три варианта:

  • ссылка на веб-сайт (корень или конкретную страницу), где нам необходимо найти все ленты.
  • прямая ссылка на фид (кстати, может быть с переадресацией внутри)
  • линк, где нет фидов.

Вот, к примеру, самый сложный фид - http://www.cnbc.com/id/19789731/device/rss/rss.xml. Я до конца так и не понял, почему с ним не может справиться компонент Zend_Feed. Я могу ошибаться, но по моему там это связано с прикрепленными стилями, которые при обработке почему-то автоматически применялись и на выходе получалась не XML, а обычная HTML-страница (но это может быть и неправдой, если кто разберется, напишите в комментариях). Поэтому пришлось попробовать новый компонент - Zend_Feed_Reader, который без труда справился.

Моя система будет работать с потоком ссылок, поэтому вполне вероятно, что ленты могут дублироваться. Да и мало ли чего, человек просто введет еще раз один и тот же адрес. Так как обработка и поиск фидов это длительная операция, связанная с сетевым доступом к удаленному ресурсу, то хотелось бы максимально разгрузить сервер. В этом нам поможет встроенная в Zend_Feed_Reader возможность кешировать данные. Да и на этапе сбора данных у нас не стоит задачи получать только актуальные новости - если мы клиенту покажем для подтверждения подписки на фид последние 10 записей, но это не будут самые-самые последние, а, допустим, с часовым опозданием, ничего особенного не произойдет. К тому же, если сервер, который отдает ленту, поддерживает правильные заголовки кеширования, то наш кеш будет автоматически проверяться и обновляться. Так мы существенно снизим нагрузку в случае массовых подписок на некоторый стандартный набор лент (не секрет, что бОльшая вероятность, что новый пользователь подпишется как раз на популярные ленты, которые уже кто-то просматривал до него, а значит лента будет в кеше).

  1. $cache = Zend_Cache::factory('Core',
  2.                              'File',
  3.          array(
  4.                 'lifetime' => 24 * 3600,
  5.                 'automatic_serialization' => true,
  6.                 'caching' => true,
  7.                 'cache_id_prefix' => 'trdata_feed_',
  8.                 'write_control' => true,
  9.                 'ignore_user_abort' => true
  10.          ),
  11.          array(
  12.                 'read_control_type' => 'adler32',
  13.                 'cache_dir' => '/tmp/cache'
  14.          ));
  15.  
  16.         Zend_Feed_Reader::setCache($cache);
  17.         Zend_Feed_Reader::useHttpConditionalGet(true);

* This source code was highlighted with Source Code Highlighter.

Теперь можно приступать непосредственно к обработке. Тестовый скрипт я пробовал на небольшом массиве адресов, поэтому результат мне было удобно выводить в массиве, ключами которого быть доменные имена, поэтому сначала я еще раз проверял все линки и вытаскивал оттуда домен (при помощи Zend_Uri_Http). В реальной системе этого, скорее всего, не понадобиться, как как мы будем обрабатывать по одному адресу за раз.

Для примера, возьмем наугад следующий список:

  1. $_url = array(    
  2.     'http://www.cnbc.com/id/19789731/device/rss/rss.xml',
  3.     'http://www.planet-php.net/',
  4.     'ajaxian.com',
  5.     'http://twitter.com/abrdev',
  6.     'http://verens.com/archives/2009/12/28/multiple-file-uploads-using-html5/');

* This source code was highlighted with Source Code Highlighter.

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

  1. //массив линков, которые готовые к обработке (валидные URI)
  2. $_links = Array();
  3.         
  4. echo "Checking URL...<br />";
  5. foreach ($_url as $u)
  6. {
  7.     echo "Original URL: " . $u . "...<br />";
  8.  
  9.     $_url = self::_validURI($u);
  10.  
  11.     if ($_url === false) continue;
  12.     else
  13.           $_links[] = $_url;        
  14. }

* This source code was highlighted with Source Code Highlighter.

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

  1. foreach ($_links as $fl)
  2. {
  3.     //пробуем извлечь URL из указанного сайта
  4.     try
  5.     {
  6.         $_lhttp = Zend_Uri_Http::fromString($fl);
  7.     if ($_lhttp->valid())
  8.     {
  9.         //проверим и получим имя сайта
  10.         $site = $_lhttp->getHost();
  11.                     
  12.         $_feeds_links[$site] = Array();
  13.     }
  14.     else
  15.         // если не вышло проверить, пропускаем
  16.         continue;
  17.     }
  18.     catch (Zend_Uri_Exception $e) { continue; }    

* This source code was highlighted with Source Code Highlighter.

Дальше мы последовательно пытаемся с каждого адреса извлечь все ленты. Используя Zend_Feed_Reader мы пробуем обнаружить на странице ленты, которые будут возвращены в виде массива объектов класса Zend_Feed_Reader_FeedSet, а по сути - просто массивы (вернее, объект просто реализует необходимые интерфейсы, поэтому с ним можно работать, как с обычным массивом. Если ленты есть, мы перебираем все и извлекаем из них свойство href, содержащее прямую ссылку. В случае, если по указанному адресу ленты отсутствуют (это случай как просто страницы без лент, так и при использовании прямого адреса фида - он воспримется также как отсутствие ленты), мы строим предположение, что, возможно, это как раз случай прямого адреса и пытаемся получить фид напрямую. Если же и эта попытка провалится, считаем, что увы, но по указанному адресу лент нет и переходим к следующему по списку адресу.

  1. try
  2. {
  3.     $_ln = Zend_Feed_Reader::findFeedLinks($fl);
  4.                 
  5.     if (($_ln instanceOf Zend_Feed_Reader_FeedSet) && (count($_ln) > 0))
  6.     {
  7.     $tmp = Array();
  8.                 
  9.     foreach ($_ln as $cf)
  10.     {
  11.          //в $cf у нас объект каждого фида, Zend_Feed_Reader_FeedSet
  12.         //он наследуется от ArrayObject и содержит три поля,
  13.             //нам интересно: 'href', содержащие ссылку на фид
  14.             $tmp[] = $cf['href'];                        
  15.     }
  16.  
  17.         //так как бывают дублирующие фиды, удалим дубликаты
  18.     if (!empty($tmp))
  19.     {
  20.         $_feeds_links[$site] = array_unique($tmp);
  21.     }
  22.     }
  23.     else
  24.     {
  25.     //это может быть прямой линк на FeedURL
  26.     // для этого придется попробовать загрузить документ
  27.     try
  28.     {
  29.         $_tmp_feed = Zend_Feed_Reader::import($fl);
  30.             
  31.             // мы не знаем наперед, какой формат
  32.         if ($_tmp_feed instanceOf Zend_Feed_Reader_FeedAbstract)
  33.         {
  34.         //да, это нормальный фид, он уже в кеше,
  35.                 //поэтому просто получим адрес, на случай использования прокси-сервисов
  36.         //как показала практика, использование getFeedLink()
  37.                 //иногда не дает нужного результата, например для CNBC-фида
  38.                             
  39.         $_feeds_links[$site][] = $fl;
  40.                         
  41.         continue;
  42.         }
  43.         else
  44.         throw new Zend_Exception('Bad feed');                        
  45.     }
  46.     catch(Zend_Exception $e)
  47.     {
  48.         //точно нет
  49.         echo "<br /><b>" . $fl . "</b> == Nothing feeds!<br />";
  50.         continue;
  51.     }
  52.     }
  53.                                 
  54. }
  55. catch (Zend_Exception $e)
  56. {
  57.     continue;
  58. }

* This source code was highlighted with Source Code Highlighter.

Обратите внимание, в случае, когда мы пытаемся загрузить фид напрямую, мы не знаем, какой формат будет, поэтому для проверки результата используем тот факт, что все классы фидов имеют общего предка, абстрактный класс Zend_Feed_Reader_FeedAbstract. Также в этом случае будет некоторое дублирование, так как дальше мы будем получать последние записи из фидов. Но так как мы используем кеширование, то для случая прямых ссылок данные уже будут в кеше, поэтому повторного запроса не будет.

Получение последних записей ленты.

Для того, чтобы предоставить пользователю выбор из нескольких лент, или просто показать, что же за фид он будет читать после подписки, мы выбираем 10 последних сообщений и показываем пользователю вместе с адресом подписки. Здесь нам не нужно выбирать все сообщение, поэтому ограничимся только заголовком и ссылкой. Первоначально я также хотел выбирать и другую информацию о ленте, например, описание или список авторов, сopyright, но оказалось, что во многих лентах этих полей просто нет (пустые), поэтому ограничимся только названием.

Если на этом этапе мы встретимся с ошибкой, то просто пропускаем ленту - в лучшем случае, на странице будет еще одна лента, но другого формата, в худшем - мы не найдем ничего. Когда лента импортирована, мы получим заголовок, а далее в цикле 10 последних записей, для каждой из которых получим ссылку, название и дату создания (дата всегда идет в GMT). В тестовом примере я сразу формирую строку, в реальности скорее всего вы каждый из компонент сохраните отдельно, а время, возможно, приведете к единому стандарту (например, с учетом текущей локали пользователя) и конвертируете в UNIX TIMESTAMP для удобства обработки.

  1. echo '<br /><br /> Retriving last feed items...<br />';
  2.         
  3. $_feeds_items = Array(); //сообщения в фиде
  4. $_item_per_feed = 10; //Сколько сообщений с ленты тянуть
  5.         
  6. foreach ($_feeds_links as $_flinks)
  7. {
  8.     if (count($_flinks) > 0)
  9.     {
  10.     foreach ($_flinks as $fl)
  11.     {        
  12.      try
  13.      {
  14.          $_x_feed = Zend_Feed_Reader::import($fl);
  15.                         
  16.          // может быть как Atom, так и RSS,
  17.              //поэтому проверяем по абстрактному классу-предку
  18.          if ($_x_feed instanceOf Zend_Feed_Reader_FeedAbstract)
  19.          {
  20.         $tmpx = Array('title' => null, 'items' => Array());
  21.                             
  22.         $tmpx['title'] = htmlspecialchars($_x_feed->getTitle(), ENT_QUOTES);                            
  23.                             
  24.         $i = 0;
  25.         foreach ($_x_feed as $fitm)
  26.         {
  27.             if ($i < $_item_per_feed)
  28.             {
  29.             $i++;
  30.             //получить название, линк и дату (в GMT)
  31.             //GUID - md5(getId());
  32.             $tmpx['items'][] = '<a href="'.$fitm->getLink().'" target="_blank">'.htmlspecialchars($fitm->getTitle(), ENT_QUOTES).'</a> at '. $fitm->getDateCreated()->toString() .' <br />';
  33.             }
  34.                  else  break;
  35.             }
  36.                             
  37.         $_feeds_items[$fl] = $tmpx;
  38.          }
  39.     }
  40.     catch (Zend_Exception $e) { continue; }
  41.      }
  42.   }                
  43. }
  44.  
  45. // посмотрим результат?        
  46. var_dump($_feeds_items);

* This source code was highlighted with Source Code Highlighter.

Результат мы пока просто выводим через var_dump в браузер (ведь это всего лишь тестовый скрипт). В реальной системе все эти данные пакуются в JSON-массив и отправляются клиенту, который отображает пользователю и дает возможность выбрать одну из лент для подписки. Конечно, можно было бы сделать все за пользователя - например, в случае нескольких лент, которые отличаются только форматом, проверять совпадение ID новостей, и если они одинаковые, то просто брать предпочтительный формат и все. Но это уже зависит от специфики конкретных задач.

Вот и все. Конечно, приведенный код просто иллюстрация и не предназначен для реального использования (особенно методом copy/past). В дальнейшем мы продолжим эту тему и попробуем написать настоящий серверный агрегатор новостей с Web 2.0 AJAX интерфейсом, реал-таймовой доставкой новых сообщений (через Comet), а также построим серверную платформу для распределенной фоновой обработки новостных потоков (так как лент может быть много и для разных лент разные настройки периодичности опроса).

Комментирование отключено.
Developers.org.ua