CustomFeed::config - 指定した URL から RSS item を抜すプラグイン(実験中)

ちょいと実験で、CustomFeed::config というのを作成中。


config には、URL だけを指定し、抜き出すパラメータの条件の詳細は
別ファイルで定義しというもの。条件には、xpath もしくは正規表現
が使える。xpath で抜き出す処理は hpricot を使っているから、
CSSセレクタとかを指定できるかも。

- module: CustomFeed::config
  config:
    link:
      - 'http://www.onsen.ag/'


前にも同じようなプラグインを作っていたけれども、未完成だったので、
今回はそれなりに形にしてみようかと。Plagger にあったものを参考に
してみた。Pragger で使えれば、僕の中ではそれなりに使えるツールに
なると思う。


HTTPCache クラスは、一度アクセスした URL のコンテンツをローカルに
保存するもので、それ以降はその URL に対しては HTTP アクセスをしない
ようにします。プラグインのテスト中何度も HTTP リクエストが飛ぶのは
良くないと思ってね。前に作ったものだけれど、若干修正しているかも。

require 'rubygems'
require 'hpricot'

class Extract
  def Extract.match(opt, &block)
    record = Extract::Item.new(opt)
    record.instance_eval(&block)
    return record.data
  end

  class Item
    attr_accessor :data

    def initialize(opt)
      @text = opt[:text]
      @data = {:summary => {}, :item => []}
    end

    def record(opt, &block)
      if opt.key?(:xpath)
        doc = Hpricot(@text)
        records = doc.search(opt[:xpath]).collect {|r| r.to_s}
      elsif opt.key?(:regexp)
        records = @text.scan(%r{#{opt[:regexp]}}m).collect {|r| r.to_s}
      end

      records.each {|r|
        prop = Extract::Property.new(r)
        prop.instance_eval(&block)
        @data[:item] << prop.data
      }

      return self
    end
  end

  class Property
    attr_accessor :text, :data

    def initialize(text)
      @text = text
      @data = {}
    end

    def item(opt)
      xpath = Hash[*opt.map {|k, v| [k, v] if v.key?(:xpath)}.compact.flatten]
      const = Hash[*opt.map {|k, v| [k, v] if v.key?(:const)}.compact.flatten]
      regexp = Hash[*opt.map {|k, v| [k, v] if v.key?(:regexp)}.compact.flatten]

      if regexp.size
        regexp.each_pair {|k, v| 
          @data[k] = @text.match(%r{#{v[:regexp]}}m).to_a[1]
          implement_method(@data, k.to_s, @data[k])
        }
      end

      if xpath.size
        doc = Hpricot(@text)

        xpath.each {|k, v| 
          elem = doc.at(v[:xpath])
          elem_val = ''
          case elem
          when Hpricot::Text
            elem_val = elem.to_s
          when Hpricot::Elem
            case v[:expr]
            when /inner_text/
              elem_val = elem.inner_text
            when /text/
              elem_val = elem.search('/').map{|e| e if e.class == Hpricot::Text }.compact.join('')
            when /@(.+)/
              elem_val = elem[$1]
            else
              elem_val = elem.inner_text
            end
          else
            elem_val = ''
          end
          @data[k] = elem_val
          implement_method(@data, k.to_s, elem_val)
        }
      end

      if const.size
      end
    end

    private
    def implement_method(obj, name, value = nil)
      obj[name] = value

      obj.instance_eval <<-EOD
        def #{name}
          self['#{name}']
        end
        def #{name}=value
          self['#{name}'] = value
        end
      EOD
    end
  end
end

require 'md5'
require 'fileutils'
require 'open-uri'

class HTTPCache
  def initialize(dir)
    @cache_dir = dir
  end

  def get(uri)
    text = ''

    unless uri =~ %r{^http://}
      open(url) {|f| text = f.read }
      return text.toutf8
    end

    if File.exist?(@cache_dir) && File.directory?(@cache_dir)
      cache_file = "#{@cache_dir}/#{Digest::MD5.new.update(uri.to_s)}.html"

      if File.file?(cache_file) && File.readable?(cache_file)
        File::open(cache_file) {|f|
          text = f.read
        }
      else
        open(uri) {|f| text = f.read }
        self.set(uri, text)
      end
    else
      open(uri) {|f| text = f.read }
      self.set(uri, text)
    end

    text.toutf8
  end

  def set(uri, text)
    FileUtils.mkdir_p(@cache_dir) unless File.exist?(@cache_dir)
    return 0 unless File.exist?(@cache_dir) && File.directory?(@cache_dir)

    cache_file = "#{@cache_dir}/#{Digest::MD5.new.update(uri.to_s)}.html"

    File::open(cache_file, "w") {|f|
      f.write(text)
    }

    return 0
  end
end

require 'kconv'
require 'yaml'

def load_assets(path)
  $stderr. puts "assets path: #{path}"

  assets = {}

  Dir::glob(path + '/*.yaml') {|file|
    $stderr.puts "assets file: #{file}"
    assets[File.basename(file)] = YAML.load(File.read(file).toutf8)
  }

  assets
end

def match_assets(assets, url)
  assets.each_value {|asset|
    $stderr.puts "assets handle: #{asset['handle']}"
    return asset  if asset.key?('handle') && url =~ %r{#{asset['handle']}}
  }
  nil
end

require 'rss'

@assets_path = File.expand_path(File.dirname($0) + '/assets/plugin/CustomFeed-config')

def config(config, data)

  assets = load_assets(@assets_path)
  $stderr.puts "number of assets: #{assets.size}"

  rss = RSS::Maker.make("2.0") {|maker|
    maker.channel.title       = ''
    maker.channel.description = ''
    maker.channel.link        = ''

    config['link'].each {|link|
      asset = match_assets(assets, link)
      if asset.nil?
        $stderr.puts "skip item; not match handle; url=#{link}"
        next
      end

      $stderr.puts "fetch url; #{link}"

      text = HTTPCache.new('./cache').get(link)

      if asset.key?('capture')
        record_config = {:regexp => asset['capture']}
      elsif asset.key?('capture_xpath')
        record_config = {:xpath => asset['capture_xpath']}
      else
        $stderr.puts "skip item; not capture paremeter; url=#{link}"
        next
      end

      items = Extract.match :text => text do
        record record_config do |r|
          if asset.key?('extract')
            asset['extract'].each_pair {|k, v|
              item({ k => { :regexp => v }})
            }
          end

          if asset.key?('extract_xpath')
            asset['extract_xpath'].each_pair {|k, v|
              item({ k => { :xpath => v, :expr => 'inner_text'}})
            }
          end
        end
      end

      if asset.key?('after_hook')
        items[:item].map! {|item|
          # TODO:
          eval(asset['after_hook'])
          item
	    }
      end

      items[:item].each {|i|
        next  if i.nil?

        rss_item = maker.items.new_item

        i.each_pair {|k, v|
          rss_item.instance_variable_set("@#{k}", v)
        }

        rss_item.title = '' if rss_item.title.nil?
      }
    }
  }

  $stderr.puts "number of item: #{rss.items.size}"

  data if rss.items.size == 0
  rss.items
end


試しに作った音泉用に作ったものを、載せておく。

  • 設定ファイル(PRAGGER_HOME/assets/plugin/CustomFeed-config/onsen.yaml)
handle: 'http://www\.onsen\.ag/'
capture_xpath: '//td[@bgcolor=#000000]'
extract:
  title: '<td width="135"[^>]?>(?:(?:<.+?>)*)\s*(.+?)\s*(?:<.+?>)'
  link: '(http://[^\s]+?\.asx)'
  author: '<td align="center"><img .+?alt="([^"]+)"'
  date: '(http://[^\s]+?\.asx)'
  description: '^'
after_hook: |
  next if item.title.nil? || item.link.nil?
  if item.date =~ /([0-9]{2}[0-9]{2}).asx/
    item.date = Time.parse($1)
  else
    item.date = Time.now
  end
  item.description = item.title + ' ' + item.author