Tag: Ruby

Simple Rails RSS Feed Reader – czytnik Feedów RSS

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

Osadzanie obrazów w CSS z pomocą Base64, Rubiego i Railsów

Pracując nad Senpuu v5 zauważyłem, że ilość teł i innych obrazków wkomponowanych w arkusze styli (CSS) niebezpiecznie rośnie. Standardowo osadzanie grafik wygląda (mniej więcej) tak:

div#id {
  background: url(/images/background.png) repeat-x;
}

Kiedy ładujemy stronę po raz pierwszy (i nie jest ona w cache'u), przeglądarka odpytuje o każdy plik z osoba - co w przypadku Senpuu v5 - daje obecnie ponad 47 żądań. Jest to trochę dużo, zwłaszcza że duża część plików jest stosunkowo mała (1kb-3kb), w skutek czego tracimy bardzo dużo czasu i mocy obliczeniowych na tego typu żądania.

Aby zminimalizować ilość żądań, możemy zastosować jedną z dwóch metod:

  • CSS Sprite's
  • base64-encoded Data URIs

O ile z pierwszej z nich korzystałem dawniej, o tyle nie przypadła mi do gustu (wkurzające osadzanie w CSSie obrazków, niewygodna rozbudowa plików z tłami). Druga metoda jednak (base64-encoded Data URIs) bardzo przypadła mi do gustu. W skrócie, polega ona na osadzaniu obrazków bezpośrednio w pliku CSS. Jak to się robi? Bardzo prosto:

div#id {
  background: url(data:image/png;base64,OBRAZEK_W_BASE64) repeat-x;
}

Zalet takiego rozwiązania jest wiele:

  • Mniejsza (często dużo np z 47 do 14) ilość żądań do serwera;
  • Inny sposób renderowania strony - "wskakuje" cała od razu;
  • Brak czasochłonnego doczytywania w tle obrazków;
  • Korzystając z GZipa - kompresja styli.

Nie ma jednak róży bez kolców, tak więc są i minusy:

  • IE7 i IE6 nie obsługują tej metody osadzania obrazków (ale kto by się tam tym przejmował ;) );
  • Osadzanie "ręcznie" jest stosunkowo niewygodne - CSS staje się "szeroki" i źle się z nim pracuje;
  • IE8 obsługuje osadzanie tylko jeśli grafiki mają mniej niż 32kb (ale to od biedy da się przeżyć);
  • Konwersja do Base64 zwiększa rozmiar grafik o ponad 30% - jednak kompresując GZipem arkusze różnica jest minimalna.

Tak jak wspomniałem powyżej, osadzanie grafik jest bardzo niewygodne, dla przykładu - tak wygląda osadzenie tła (wybrałem stos. mały plik a i tak go "złamałem"):

div#id {
  background: #263E55 url(data:image/png;base64,iVBORw
    0KGgoAAAANSUhEUgAAAAEAAAAZCAIAAAB/8tMo
    AAAACXBIWXMAABv9AAAb/QGX0Qc2AAAAMUlEQ
    VR42kWLuREAIAzDdN6JRZiE/YuIgnCpLH+sfYJGeK
    qRGtZY31dno3T/t/R/mAtRMDvkNuHCUwAAAABJRU
    5ErkJggg==) repeat-x;
}

Taki kod (zwłaszcza jak ktoś ma w IDE włączone łamanie wierszy) bardzo utrudnia rozczytanie CSSa. Osadzanie plików jest proste - można skorzystać z jednego z konwerterów do i z base64. Na dłuższą metę jest to jednak niewygodne, ponieważ jeśli zmienimy plik, musimy go od nowa przekonwertować i wstawić w arkusz. Z tego względu opracowałem małą klasę i metodę pomocniczą dla Railsów. Klasa ta konwertuje arkusz styli, zamieniając "zewnętrzne" obrazki na osadzone. Metoda pomocnicza umożliwia umieszczanie arkuszy styli na stronie, osadzając obrazki w CSSie tylko w pliku podlegającym cachowanie (nie w oryginalnych CSSach), tak aby nie "uszkodzić" naszych oryginalnych plików CSS.

Całość jest niewielka - rozbija się w zasadzie o regexpy. Poniżej kod i jego analiza. Najpierw zajmiemy się helperem który wygląda tak:

  # Musiałem to przepisać i dodać konwersję z teł osadzanych z zewnątrz
  # na osadzane w base64 (pod warunkiem że ważą mniej niż 32kb)
  def base64_stylesheet_link_tag(*sources)
    options = sources.extract_options!.stringify_keys
    concat  = options.delete("concat")
    cache   = concat || options.delete("cache")
    recursive = options.delete("recursive")

    if concat || (config.perform_caching && cache)
      joined_stylesheet_name = (cache == true ? "all" : cache) + ".css"
      joined_stylesheet_path = File.join(joined_stylesheet_name[/^#{File::SEPARATOR}/] ? config.assets_dir : config.stylesheets_dir, joined_stylesheet_name)

      unless config.perform_caching && File.exists?(joined_stylesheet_path)
        write_asset_file_contents(joined_stylesheet_path, compute_stylesheet_paths(sources, recursive))
        css = CssConverter.new(
          :path => joined_stylesheet_path,
          :overwrite => true,
          :root => File.join(Rails.root, 'public'))
        css.convert!
        css.save!
      end
      stylesheet_tag(joined_stylesheet_name, options)
    else
      sources = expand_stylesheet_sources(sources, recursive)
      ensure_stylesheet_sources!(sources) if cache
      sources.collect { |source| stylesheet_tag(source, options) }.join("\n").html_safe
    end
  end

Kod ten nie będzie (w większości) podlegał wyjaśnieniu, ponieważ nie licząc 3 wierszy kodu, reszta to dokładna kopia metody stylesheet_link_tag.

Trzy wiersze o których wspominałem wyżej to:

        css = CssConverter.new(
          :path => joined_stylesheet_path,
          :overwrite => true,
          :root => File.join(Rails.root, 'public'))
        css.convert!
        css.save!

Najpierw tworzę obiekt klasy CssConverter, podając mu ścieżkę do cachowanego przez Railsy (linijkę wyżej ;) ) pliku joined_stylesheet_path, informuję Railsy że mają nadpisać arkusz styli moim z osadzonymi obrazkami (:overwrite => true) oraz podaję ścieżkę domyślną, skąd obiekt ma pobierać obrazki (:root => File.join(Rails.root, 'public')). Następnie konwertuję i zapisuję wygenerowany plik CSS.

Warto zauważyć, że całość konwersji i zapisu dokonuje się tylko jeśli plik CSS nie był cachowany (i cachowanie jest w ogóle włączone):

unless config.perform_caching && File.exists?(joined_stylesheet_path)

Dzięki temu, zamiana na base64, osadzanie w CSSie i zapis odbywają się tylko podczas pierwszego żądania arkusza.

Zanim przejdziemy do omawiania klasy konwertującej, zdefiniujmy sobie dwa wyjątki (omówienie w komentarzu nad wyjątkiem):

module Exceptions
  # Podany plik z CSSem do przerobienia nie istnieje
  class CssNotFound < StandardError; end

  # Nie podano ani pliku ani styli bezpośrednio
  # (czyli nie ma z czego robić konwersji)
  class CssStringNotProvided < StandardError; end
end

Klasa CssConverter zawiera łącznie 5 metod:

  1. initialize(*sources) - do wywoływana CssConverter.new,
  2. convert! - dokonuje konwersji,
  3. save! - zapisuje,
  4. (private) pull - "wyciąga" ścieżki do obrazków z oryginalnego CSSa,
  5. (static) file_to_base64 - zamienia plik spod podanej ścieżki na base64.

Na początek szkielet obiektu:

require 'base64'

class CssConverter
  DEFAULT_MAX_IMG_SIZE = 32*1024

  attr_reader :result
end

Maksymalny rozmiar pliku jaki będzie osadzany to 32kb. Dajemy sobie też możliwość odczytania wyniku bezpośrednio z obiektu.

Metoda inicjalizacyjna:

  def initialize(*sources)
    options = sources.extract_options!.stringify_keys
    source  = options.delete("source")
    @file_path = options.delete("path")
    @root_path = options.delete("root")
    @overwrite = options.delete("overwrite")
    @result = nil

    @img_max_size = DEFAULT_MAX_IMG_SIZE || options.delete("img_max_size")

    if @file_path
      raise Exceptions::CssNotFound unless File.exists?(@file_path)
      @source = File.open(@file_path, 'r') { |file| file.read }
    else
      raise Exceptions::CssStringNotProvided unless source
      @source = source
      @overwrite = false
    end
  end

Pobieramy w niej potrzebne nam parametry oraz sprawdzamy czy mamy "z czego" robić konwersję CSSa (czy dano nam jakieś dane). Parametry jakie wprowadzamy to:

  • path - ścieżka do źródłowego pliku CSS (wraz z rozszerzeniem!) z którego mamy konwertować,
  • source - lub ewentualnie string zawierający CSS który mamy poddać konwersji,
  • root - katalog z którego zaczynamy przeszukiwanie plików z obrazami do osadzenia,
  • overwrite - czy przy zapisie mamy nadpisać nasz źródłowy plik CSS z którego pobieraliśmy dane.

Inicjalizujemy sobie zmienne instancyjne, sprawdzamy czy jest z czego konwertować (jak nie to wyrzucamy wyjątek).

Zajmijmy się teraz bodaj najważniejszą metodą, metodą prywatną pull:

  # Wyciąga urle - i je w razie czego poprawia
  # Zwraca tablice [[:original, :converted]]
  def pull
    # Całość wraz z url czyli np url('/images/cos.png')
    whole_urls = @source.scan(/url\(.+\)/i)

    return nil if whole_urls.count == 0

    # Sama scieżka czyli np /images/cos.png
    img_bg_paths = []
    whole_urls.each do |url|
      img_bg_paths << url.scan(/url\(\s*["']?([^"']+)["']?\s*\)/i)[0][0]
    end
    img_bg_paths_original = img_bg_paths.clone

    img_bg_paths.collect! do |img|
      img = img.gsub('./', '/')
      img = "/#{img}" if img.first != '/'
      img
    end

    result = []
    img_bg_paths.each_with_index do |el, i|
      result <<
        {:original_path =>img_bg_paths_original[i],
        :converted_path => el,
        :original_url => whole_urls[i]
        }
    end
    result
  end

W metodzie tej wyodrębniamy najpierw cały fragment z CSSa zawierający ścieżkę:

url(/images/plik.png)

Potrzebujemy całości, ponieważ zostanie ona zamieniona na nowy osadzony fragment. Potrzebujemy też samą ścieżkę do pliku, co wyodrębniamy drugim regexpem. Następnie zamieniamy ew. odniesienia względne na bezwzględne i dodajemy ukośnik na początek (jeśli nie było). Potem całość zgrabnie pakujemy do jednej tablicy i zwracamy jako wynik. Po odpaleniu tej metody, mamy więc ścieżki do plików, oraz fragmenty które musimy naszym regexpem zamienić. Nie pozostaje nam nic innego jak zamienić co trzeba ma base64 i umieścić w naszym arkuszu.

Aby zamieniać pliki na base64 wykorzystamy metodę statyczną file_to_base64:

  # Zamienia plik na base64
  def self.file_to_base64(path)
    str = File.open(path, 'r') { |file| file.read }
    Base64.encode64(str).gsub("\n", '')
  end

Standardowa metoda z Rubiego - encode64 zwraca stringa ze znakami nowych wierszy (taka naleciałość), z tego względu jest na końcu gsub.

Konwersja jest całkiem prosta - przelatujemy gsubem "starego" CSSa i zamieniamy konkretne fragmenty na nasze nowe, osadzone. Następnie rezultat przypisujemy do zmiennej instancyjnej:

  # Konwertuj zadany styl osadzając w nim obrazki w base64
  def convert!
    if elements = pull
      source = @source
      elements.each do |el|
        file_base64 = File.join(@root_path, el[:converted_path])
        file_ext = el[:converted_path].split('.').last
        begin
          next if File.size?(file_base64) > @img_max_size
          file_base64 = self.class.file_to_base64(file_base64)
          source.gsub!(el[:original_url], "url(data:image/#{file_ext.downcase};base64,#{file_base64})")
        rescue
          next
        end
      end
      @result = source
    else
      @result = @source
    end
  end

Warto zauważyć, że chronimy się także przed ew. brakiem pliku do odczytu. Jeśli pliku nie ma, to po prostu pozostawiamy to tło, takim jakie jest. To samo tyczy się rozmiaru pliku - jeśli jest za duży to go nie wrzucamy do arkusza.

Pozostaje nam tylko bajecznie prosty zapis:

  # Zapisuje do pliku
  def save!(filename = nil)
    if @file_path && @overwrite && filename.nil?
      p = @file_path
    else
      p = filename
    end
    f = File.new(p, "w")
    f.write(@result)
    f.close
  end

Koniec. Całość wygląda tak:

require 'base64'

class CssConverter
  DEFAULT_MAX_IMG_SIZE = 32*1024

  attr_reader :result

  def initialize(*sources)
    options = sources.extract_options!.stringify_keys
    source  = options.delete("source")
    @file_path = options.delete("path")
    @root_path = options.delete("root")
    @overwrite = options.delete("overwrite")
    @result = nil

    @img_max_size = DEFAULT_MAX_IMG_SIZE || options.delete("img_max_size")

    if @file_path
      raise Exceptions::CssNotFound unless File.exists?(@file_path)
      @source = File.open(@file_path, 'r') { |file| file.read }
    else
      raise Exceptions::CssStringNotProvided unless source
      @source = source
      @overwrite = false
    end
  end

  # Konwertuj zadany styl osadzając w nim obrazki w base64
  def convert!
    if elements = pull
      source = @source
      elements.each do |el|
        file_base64 = File.join(@root_path, el[:converted_path])
        file_ext = el[:converted_path].split('.').last
        begin
          next if File.size?(file_base64) > @img_max_size
          file_base64 = self.class.file_to_base64(file_base64)
          source.gsub!(el[:original_url], "url(data:image/#{file_ext.downcase};base64,#{file_base64})")
        rescue
          next
        end
      end
      @result = source
    else
      @result = @source
    end
  end

  # Zapisuje do pliku
  def save!(filename = nil)
    if @file_path && @overwrite && filename.nil?
      p = @file_path
    else
      p = filename
    end
    f = File.new(p, "w")
    f.write(@result)
    f.close
  end

  # Zamienia plik na base64
  def self.file_to_base64(path)
    str = File.open(path, 'r') { |file| file.read }
    Base64.encode64(str).gsub("\n", '')
  end

  private

  # Wyciąga urle - i je w razie czego poprawia
  # Zwraca tablice [[:original, :converted]]
  def pull
    # Całość wraz z url czyli np url('/images/cos.png')
    whole_urls = @source.scan(/url\(.+\)/i)

    return nil if whole_urls.count == 0

    # Sama scieżka czyli np /images/cos.png
    img_bg_paths = []
    whole_urls.each do |url|
      img_bg_paths << url.scan(/url\(\s*["']?([^"']+)["']?\s*\)/i)[0][0]
    end
    img_bg_paths_original = img_bg_paths.clone

    img_bg_paths.collect! do |img|
      img = img.gsub('./', '/')
      img = "/#{img}" if img.first != '/'
      img
    end

    result = []
    img_bg_paths.each_with_index do |el, i|
      result <<
        {:original_path =>img_bg_paths_original[i],
        :converted_path => el,
        :original_url => whole_urls[i]
        }
    end
    result
  end
end

Poszczególne pliki do pobrania:

Copyright © 2026 Closer to Code

Theme by Anders NorenUp ↑