不格好エンジニア (引っ越しました)

wordpress.comから引っ越しました。

仕事を楽しくする【AWSを使わない】はてぶの記事をサムネイル付きでJSON配信する3ステップ

はじめに

類似するネタは、オンライン上に色々な方が書いて下さっているのですが、"サムネイルはAWS S3に保存しよう!"的なネタが多く、VPS一台で処理を完結したい自分にとっては、そのものズバリの記事がありませんでした。需要があるかどうかわかりませんが、書いてみたいと思います。

おおまかに実装方針を立てると、このようになります。

  • はてぶのAPIから記事を収集する(XMLをフェッチしてパース)
  • はてぶの記事URLからサムネイルを生成してDBに保存(dragonflyもしくはcarrierwaveを用います。どちらでも可能です)
  • 記事とサムネイルをJSON形式で配信する

今回は主要部分を抜粋してご紹介します。ソースコードはこちらに公開しております。

tjnet/geeknews-rails · GitHub

(1)はてぶのAPIから記事を収集する(XMLをフェッチしてパース)

まずはとにかく記事データが必要ということで、railsを用いて記事を収集します。データ収集に利用したのは以下のコードです。

#coding: utf-8
require 'pp'
require 'net/http'
require 'uri'
require 'rexml/document'

module Tasks
  class ArticlePicker
    # initialize article data
    def self.execute
      ActiveRecord::Base.connection.execute("TRUNCATE TABLE articles")
  
      feed_list = Feed.all
      articles = []
  
      feed_list.each do |feed|
        next if feed.url.blank?
        articles = fetch_and_parse(feed.url)
        save_articles(articles, feed.category_id)
      end
    end
  
    # save articles
    def self.save_articles(articles, category_id)
      return if articles.empty?
      pp '--'
      pp Time.now
  
      articles.each do |article|
      #pp sprintf('save %s of %d', article['title'], category_id)
      pp sprintf('save %s of %d', article['link'], category_id)

        Article.create!(
          :category_id => category_id,
          :title => article['title'], 
          :link => article['link'], 
          :description => article['description'], 
        )

      end
    end
  
    # return parsed articles
    def self.fetch_and_parse(url)
      res = fetch(url)
      articles = xml_to_array(res)
      pp articles
      articles
    end
  
  
    # fetch response from url
    def self.fetch(url)
      pp URI.escape(url)
  
      uri = URI.parse URI.escape(url)
      res = Net::HTTP.get uri
  
      res
    end
  
    # return parsed XML
    def self.xml_to_array(xml)
      items_rx = []
      doc = REXML::Document.new xml
      doc.elements.each('//item') do |e|
        i = Hash.new
        i["title"] = e.elements['title'].text
        i["link"] = e.elements['link'].text
        i["description"] = e.elements['description'].text
        items_rx << i
      end
  
      return items_rx
  
    end
  
  end
end

リトライ処理などは、別のスクリプトに切り出しています。

    begin
      pp "updating article ..."
      Tasks::ArticlePicker.execute
    rescue Exception => e
      pp "[ERROR] update_article #{e.message}"
      retry_count += 1
      retry if retry_count <= 5
      pp "retried 5 times so exit"
      exit
    end
    pp "updated article"

(2)はてぶの記事URLからサムネイルを生成してDBに保存(dragonflyもしくはcarrierwaveを用います。どちらでも可能です)

URLからサムネイルを作成するためにwkhtmltoimageを実行しています。
DBへの保存はcarrierwaveを用いています。

   # Generate and assign an image or set a validation error
    begin
      tempfile = temp_thumbnail_path
      cmd = "wkhtmltoimage --quality 40 --width 50 --height 50 \"#{self.link}\" \"#{tempfile}\""
         p "*** grabbing thumbnail: #{cmd}"
      system(cmd) # sometimes returns false even if image was saved
      
      self.photo = File.new(tempfile) # will throw if not saved
      self.save
    rescue => e
         p "*** thumbnail error: #{e}"
      self.errors.add(:base, "Cannot generate thumbnail. Is your URL valid?")
    ensure
    end

DBへのデータ保存が終われば、wkhtmltoimageで生成した一時ファイルは不要となりますので、modelのコールバックで後始末します。

 #Cleanup temp files when we are done
  after_save :cleanup_temp_thumbnail
   
  # Cleanup the temporary thumbnail image
  def cleanup_temp_thumbnail
    File.delete(temp_thumbnail_path) rescue 0
  end

(3)記事とサムネイルをJSON形式で配信する

ここまででDBに画像データを保存できました。後は、これをcontrollerからJSONとして配信します。

class Api::V1::ArticleController < ApplicationController
 # return articles
 def index
   render json: Article.list(request)
 end
end
 # 
 #= return articles as array of hash
 #
 def self.list(request)
   res = Array.new
   Article.all.each do |article|
     res.push({
       :category_id => article.category_id,
       :title => article.title,
       :link => article.link,
       :description => article.description,
       :image_url =>(article.photo.blank?) ? '' :  "#{request.protocol}#{request.host_with_port}#{article.photo.url}",
     })
   end
   res
 end

記事URLを生成するために、controllerからrequestを受け取っています。
もう少し簡潔に書けそうですが。。。

はまりどころ

うまく動かないときは、このあたりをチェックしてみて下さい。

  • サムネイルを一時的に保存するディレクトリのパーミッションなど
  • public/以下のディレクトリ構造
  • 本番環境だけで動かないときは、デプロイ設定やrackサーバの再起動が正常になされているか

TODO

次は、このあたりに取り組みたいですね。

  • cronの設定
  • サムネイルのクオリティを向上させる
  • 静的ファイルの配信を高速化