Wstęp

v5 (a jakże ;) ) wyposażone będzie (a w sumie to już jest) w dość miły Feed Reader. Z tego względu, postanowiłem pokazać Wam jak zrobić podobny (tylko skromniejszy) samemu. Co ma nasz czytnik oferować?

  • Możliwość podpinania się pod różne kanały
  • Możliwość filtrowania newsów - wg. słów kluczowych w polu tytułu
  • Ma weryfikować czy link działa (status 200)
  • Możliwość odświeżania wszystkich subskrypcji naraz

W tym mini tutorialu skupię się jedynie na samej idei, tak więc nie będzie ani testów ani niczego "super" zaawansowanego. Będzie to jednak dobra baza do napisania swojego większego. W tutorialu pomijam takie "banałki" jak kod do generowania modeli, itp. Tego jest pełno w innych poradnikach. Zaczynajmy więc :)

Struktura modeli

Najpierw sama struktura modeli. Będziemy mieli subskrypcje (Subscription) oraz same newsy zwane Feedami (Feed). Żeby było fajniej, całość zamkniemy w przestrzeni RSS, tak więc:

  • Rss::Subscription - odpowiada  subskrypcji pojedynczego kanału
  • Rss::Feed - odpowiada pojedynczej wiadomości z kanału

Szkielet subskrypcji wygląda następująco:

require 'rss'
require 'date'

class Rss::Subscription < ActiveRecord::Base
  set_table_name "rss_subscriptions"
  # Reszta kodu którym zajmiemy się za chwilkę :)
end

Pola jakie nasza subskrypcja ma mieć (a które sami dopiszecie do pliku migracji ;) ) to:

  • source - link do źródła feedów
  • refreshed_at - data ostatniej aktualizacji subskrypcji
  • name - nazwa kanału
  • description - opis kanału
  • filter_keywords - opcjonalne słowa kluczowe do filtracji

Pierwszą rzeczą jaką musimy dodać, jest info o tym że nasza subskrypcja może mieć feedy:

  has_many :feeds, :class_name => 'Rss::Feed', :dependent => :destroy

Dalej powinny byc walidacje jak np:

  validates_uniqueness_of :name
  validates_uniqueness_of :source

I tego typu walidacje, które są tak proste, że pozostawię je wam :) - trzeba sprawdzić poprawność URLa źródła, długość nazwy i opisu i ew. słowa kluczowe (jeśli są wymagane).

Metody modelu Rss::Subscription

Zanim przejdziemy dalej i rozbudujemy sobie nasz model, musimy zrobić listę metod które ma obsługiwać, a także stworzyć model pojedynczego Feeda. Tak więc metody modelu Rss::Subscription:

  • self.refresh_all! - odświeża wszystkie kanały sprawdzając czy są nowe wiadomości
  • refresh! - odświeża pojedynczy kanał
  • validate_all! - usuwa stare niedziałające feedy (prowadzące do 404 lub 500 - ogólnie takie które nie zwracają 200)
  • validate! - jw. ale dla pojedynczego kanału
  • private has_keyword?(string) - czy podany string (w naszym wypadku tytuł feeda) zawiera któreś ze słów kluczowych (pod warunkiem, że je sprawdzamy)

Rss::Feed

Przejdźmy do pojedyńczego Rss::Feed'a (szkielecik):

require 'net/http'
require 'digest/sha2'

# Odpowiednik jednego wpisu z RSSa
class Rss::Feed < ActiveRecord::Base
  set_table_name "rss_feeds"

  # Reszta kodu do obsługi wpisu
end

Poinformujmy Railsy, że ten model do kogoś należy (do Rss::Subscription) i dodajmy scope'y:

  default_scope order("published_at DESC")
  scope :recent, joins(:subscription).limit(8)
  belongs_to :subscription, :class_name => 'Rss::Subscription'

A oto jakie pola zawiera model Feeda:

  • subscription_id - klucz obcy
  • hash - hash do sprawdzania unikalności wpisu
  • title - tytuł
  • description - opis
  • address - adres zwrotny z feeda - do otwarcia danej wiadomości na portalu źródła
  • published_at - data publikacji na źródle

Metod które ma Feed za dużo nie będzie:

  • working? - czy adres zwrotny jest poprawny
  • private create_hash - tworzy nowy unikatowy hash - potrzebne aby nasz czytnik nie tworzył wpisów które już mamy w systemie.

Najpierw zajmijmy się metodą working?:

  # Czy dany wpis jest aktywny na serwerze (czy status 200)
  def working?
    begin
      case Net::HTTP.get_response(URI.parse(self.address))
      when Net::HTTPSuccess then true
      else false
      end
    rescue
      false
    end
  end

Sprawdza ona czy odpowiedź z adresu jest poprawna (Success czyli 200). Jeśli tak, to zwraca true, jeśli nie to false.

Do zrealizowania mamy jeszcze prywatną metodę create_hash która tworzy nam hash weryfikujący unikalność danego feeda - w obrębie danej subskrypcji. Możnaby zapytać, dlaczego nie weryfikujemy tylko daty wpisu - przecież nie zdarza się za często, że dwa wpisy są o tej samej porze. Fakt - nie zdarza się za często w obrębie jednej subskrypcji, ale zdarzyć się może w obrębie wielu.

  private

  # Tworzy hash do sprawdzania unikalnosci danego feeda
  def create_hash
    self.hash = Digest::SHA2.hexdigest("#{self.published_at}#{self.subscription_id}")
  end

Hash składa się z daty publikacji oraz klucza obcego, przez co weryfikacja unikalności odbywa się w obrębie danej subskrypcji. Można "dorzucić" jeszcze tytuł, jednak tytuł może ulec zmianie a wtedy hash ulegnie zmianie, przez co otrzymamy dwa razy ten sam wpis. Można by było weryfikować także adres, jednak w wypadku reorganizacji serwisu - adresy mogłyby się po pewnym czasie "nakładać" a feedy nie byłyby dodawane.

Informujemy Railsy, że przed walidacją warto stworzyć hash oraz że ma być on unikatowy:

  before_validation :create_hash
  validates_uniqueness_of :hash

i to by było na tyle jeśli chodzi o Rss::Feed.

Dokończenie modelu Rss::Subscription

Teraz już będzie z górki. Najpierw metoda klasowa validate_all!:

  # Usuwa niedziałające feedy ze wszystkich kanałów
  def self.validate_all!
    self.all.each do |el|
      el.validate!
    end
  end

Oraz jej wersja dla danej instacji (co kolejno wywołujemy wyżej):

  # Usuwa niedziałające feedy (takie których link nie działa)
  def validate!
    self.feeds.each do |feed|
      feed.destroy unless feed.working?
    end
  end

Warto pamiętać, że jeśli nie będzie dostępu do internetu, to linki także zostaną uznane za niedziałające i zostaną usunięte. Warto np. wprowadzić dwustopniowe usuwanie - jeśli link nie działa - to jest on oznaczany jako nieaktywny - i sprawdzany w późniejszym okresie po raz 2. Ale to pozostawiam wam :)

Odświeżenie wszystkich subskrypcji:

  # Aktualizacja wszystkich subskrypcji
  def self.refresh_all!
    self.all.each do |el|
      el.refresh!
    end
  end

Sprawdzanie słów kluczowych:

  private

  def has_keyword?(string)
    return true if self.filter_keywords.nil? || self.filter_keywords.length == 0

    keywords = self.filter_keywords.downcase.split(',')
    downcase_title = string.downcase
    keywords.each do |keyword|
      return true if downcase_title.include?(keyword.strip)
    end

    return false
  end

I najprzyjemniejsze ^^, kod odświeżający pojedynczą subskrypcję:

  # Odswiezamy kanał RSS
  def refresh!
    self.refreshed_at = Time.now.to_s(:db)
    # Jesli odswiezamy instancję - to musi być ona poprawna
    # W zasadzie korzystałem z tego w czasie testów, ale zostawiłem tak na zapas ;)
    return unless self.valid?

    # Próbujemy otworzyć kanał RSS i pobrać feedy
    begin
      new_feeds = RSS::Parser.parse(open(self.source).read, false).items
    rescue
      new_feeds = []
    end

    # Spróbuj przeparsować kolejne wpisy - jeśli się nam nie udało - to nie ma wpisów
    # i nic nie zostanie przeparsowane
    new_feeds.each do |f|
      begin
        title = f.title.to_s
        published_at = DateTime.parse(f.pubDate.to_s).to_s(:db)
        address = f.link.to_s
        description = f.description.to_s
        # Przejdz do nastepnego feeda ten nam nie pasuje do słów kluczowych
        next unless has_keyword?(title)
        # Jesli takiego feeda nie było - to go zapisz
        data = {:title => title, :published_at => published_at, :address => address, :description => description}
        new_feed_record = self.feeds.new(data)
        new_feed_record.save! if new_feed_record.valid? && new_feed_record.working?
      rescue
        # Jeżeli coś poszło nie tak - przeskocz do następnego feeda
        next
      end
    end
  end

No i to by było na tyle! Warto przetestować na koniec :) :

sub = Rss::Subscription.new
rss.title = "Dev blog"
rss.source = 'http://dev.mensfeld.pl/feed/'
rss.refresh!
rss.recent.each {|feed| p feed.title }

Źródła