任意の地域名でじゃらんのエリアコードを検索する

じゃらんのWebサービスは、地域ごとに振られたエリアコードか、地図の座標を引数として情報を引っぱってくるので、任意の地域名から情報を検索しようとするとジオコーディングが必要になる。
ジオコーディングを提供するWebサービスはGoogleをはじめとしていくつかあるが、もう少し手軽に使いたかったので、転置インデックスでエリアコードを検索するコードを書いてみた。*1

mkidx.rb

まずエリアコード対応表から転置インデックスを作成する。

#!/usr/bin/env ruby
$KCODE = 'u'
require 'optparse'
require 'rexml/document'

opt = OptionParser.new
xmlfil = nil
opt.on('-f', '--file=XML_FILE')    {|v| xmlfil = v }
opt.parse!(ARGV)

unless xmlfil
  $stderr.puts opt.help
  exit 1
end

# parse area.xml
areas = []

open(xmlfil) do |source|
  attrs = lambda {|e|  { :name => e.attributes['name'], :cd => e.attributes['cd'] } }

  REXML::Document.new(source).each_element('Area/Prefecture') do |pref|
    pref_attrs = attrs[pref]

    pref.each_element('LargeArea') do |larea|
      larea_attrs = attrs[larea]

      larea.each_element('SmallArea') do |sarea|
        sarea_attrs = attrs[sarea]
        areas << { :pref => pref_attrs, :larea => larea_attrs, :sarea => sarea_attrs }
      end
    end
  end
end

# make index
index = {}

areas.each_with_index do |area, id|
  id += 1
  name = [:pref, :larea, :sarea].map {|k| area[k][:name] }.join
  name.gsub!(/\343\203\273|\357\275\245|\357\274\210|\357\274\211/, '')
  cs = name.split(//)
  bigrams = ([nil] + cs).zip(cs)
  bigrams.shift
  bigrams.pop

  bigrams.each do |i|
    bigram = i.join
    index[bigram] ||= []
    index[bigram] << id
  end
end

# output index
puts areas.length

index.each do |bigram, ids|
  puts "#{bigram}:#{ids.join(',')}"
end


$ wget http://jws.jalan.net/content/data/area.xml
$ ./mkidx.rb -f area.xml > area.idx
作成されたインデックスはこんな感じ。

679
天王:210,452,452,453,454
騨下:375
住浜:478,479,480
………

mklst.rb

次にエリアコードと地域名のリストを作る

#!/usr/bin/env ruby
$KCODE = 'u'
require 'optparse'
require 'rexml/document'

opt = OptionParser.new
xmlfil = nil
opt.on('-f', '--file=XML_FILE')    {|v| xmlfil = v }
opt.parse!(ARGV)

unless xmlfil
  $stderr.puts opt.help
  exit 1
end

# parse area.xml
areas = []

open(xmlfil) do |source|
  attrs = lambda {|e|  { :name => e.attributes['name'], :cd => e.attributes['cd'] } }

  REXML::Document.new(source).each_element('Area/Prefecture') do |pref|
    pref_attrs = attrs[pref]

    pref.each_element('LargeArea') do |larea|
      larea_attrs = attrs[larea]

      larea.each_element('SmallArea') do |sarea|
        sarea_attrs = attrs[sarea]
        areas << { :pref => pref_attrs, :larea => larea_attrs, :sarea => sarea_attrs }
      end
    end
  end
end

# output index
areas.each_with_index do |area, id|
  id += 1
  names = [:pref, :larea, :sarea].map {|k| area[k][:name] }.join(',')
  cds = [:pref, :larea, :sarea].map {|k| area[k][:cd] }.join(',')
  puts "#{id}:#{names}:#{cds}"
end


$ ./mklst.rb -f area.xml > area.lst
作成されたリストはこんな感じ。

1:北海道,札幌,ススキノ・大通:010000,010200,010202
2:北海道,札幌,北大・丘珠:010000,010200,010205
3:北海道,札幌,琴似・テイネ:010000,010200,010208
………

search.rb

最後に任意の地域名からエリアコードを検索する。

#!/usr/bin/env ruby
$KCODE = 'u'
require 'nkf'
require 'optparse'

opt = OptionParser.new
idxfil = nil
lstfil = nil
phrase = nil
nkfopt = nil
opt.on('-i', '--index=INDEX_FILE') {|v| idxfil = v }
opt.on('-l', '--list=LIST_FILE')   {|v| lstfil = v }
opt.on('-p', '--phrase=PHRASE')    {|v| phrase = v }
opt.on('-x', '--nkf=OPTION')       {|v| nkfopt = v }
opt.parse!(ARGV)

if nkfopt
  phrase = NKF.nkf(nkfopt, phrase)
end

unless idxfil and lstfil and phrase
  $stderr.puts opt.help
  exit 1
end

# read index
n = nil
index = {}

open(idxfil) do |f|
  n = f.readline.to_i

  f.each_line do |l|
    bigram, ids = l.strip.split(/:/, 2)
    ids = ids.split(/,/).map {|i| i.to_i }
    index[bigram] = ids
  end
end

# read list
list = {}

open(lstfil) do |f|
  f.each_line do |l|
    id, names, cds = l.strip.split(/:/, 3)
    names = names.split(/,/)
    cds = cds.split(/,/)
    list[id.to_i] = [names, cds]
  end
end

# calc TF
phrase = phrase.split(/\s|\343\200\200/).select {|i| not i.empty? }
tfs = (0...phrase.length).map { {} }

phrase.each_with_index do |w, i|
  cs = w.split(//)
  bigrams = ([nil] + cs).zip(cs)
  bigrams.shift if cs.length > 1
  bigrams.pop

  bigrams.each do |chars|
    bigram = chars.join
    tfs[i][bigram] ||= 0
    tfs[i][bigram] += 1
  end
end

# calc score
scores = {}
(1...n).each {|i| scores[i] = Array.new(phrase.length, 0) }

tfs.each_with_index do |ws, i|
  ws.each do |w, tf|
    if w.split(//).length > 1
      bigrams = [w]
    else
      r = Regexp.compile(Regexp.escape(w))
      bigrams = index.keys.select {|key| r =~ key }
    end

    bigrams.each do |bigram|
      ids = index[bigram] || []
      df = ids.length
      idf = Math.log(n / (df + 1))
      tfidf = tf * idf

      ids.each do |id|
        scores[id][i] += tfidf
      end
    end
  end
end

# sort list
sorted = scores.sort_by {|id, score| score.map {|i| -i } }

sorted = sorted.map do |id, score|
  names = list[id][0]
  cds = list[id][1]
  [id, names, cds, score]
end

# output result
sorted.each do |id, names, cds, score|
  if score.all? {|i| i.nonzero? }
    puts "#{cds[2]}:#{names.join('/')}(#{score.join(', ')})"
  end
end

「横浜」をキーワードにして検索するとこんな感じになる。


$ ./search.rb -i area.idx -l area.lst -p "横浜" -x "-Sw" | nkf -s
140202:神奈川県/横浜/横浜・ベイエリア(9.45477563742468)
140211:神奈川県/横浜/新横浜・青葉(9.45477563742468)
140208:神奈川県/横浜/戸塚・港南(4.72738781871234)

ちなみ「津」と入力すると、検索されない…orz
→てきとーに修正。これはひどいかも。

最後の「if score.all? {|i| i.nonzero? }」を「if score.any? {|i| i.nonzero? }」にするとOR検索になる。

*1:ロジックはhttp://chalow.net/2007-11-26-5.htmlを使わせてもらった