汎用的クローラーの構築

こんにちは、エンジニアの建三です。

クローラーを作る際に必要なものは大きく分けて3つあります。

1. URLを取得するスパイダー - 不動産の例で言うと、サイトに掲載されている全物件の詳細ページのURLを取得するスパイダーが必要です。

2. XPath(あるいはCSS Selector)の特定 - 物件名や家賃などの物件情報を取得する為のXPathが必要です。

3. テキストの整形 - XPathで得られるのはあるDOMのテキストなので、更にそのテキストから求めてる情報を取得する必要があります。

これ以外の機能(定期的にクローリングする、エラーの対処など)は、サイトに関わらず共通するものですので、サイト毎に必要なのは上記の3つになります。

アプローチ

汎用的なクローラーには主に2つのアプローチがあります。

GUIXpathを特定する方法

1つ目はPortiaのように、GUIで取得したい情報のDOMを指定する方法です。これは上記の2を簡単にしたものと言えます。この方法だと確実に必要な情報が取得出来ますが、幾つか欠点があります。

それでも1000サイトやるのは大変 早ければ1サイト30分くらいで出来るかもしれませんが、慣れるまで時間がかかります。

DOM構造が変わったらやり直さなければならない 将来DOM構造が変わった時にまたXPathの特定をしなければいけないのでメンテナンスが大変です。

結局整形はしなければならない 3は自動化出来てないので、1サイト毎に作らなければなりません。

自動でXpathを特定&整形する方法

もう1つの方法は、自動的にXpathを特定する方法です。当然これが出来たらこっちの方が楽なので、精度次第ということになります。

この方法は前例があまり見つからず、どこまで難しいのかあまり予測出来ませんでした。社内でどちらのアプローチを取るか意見が分かれましたが、一ヶ月後者を試してみて、ダメだったら諦めて前者のアプローチを取るということになりました。

あれから2ヶ月後...

結論から言うと、1ヶ月の開発でそこそこのものが出来、自動でいけるなというのを確信しました。更に1ヶ月開発を続け、10サイトで9割の情報が自動で取れるまでになりました。

元々完全自動は無理だと分かっていて9割取れれば良い方だと思っていたので、期待以上のものが出来ました。勿論開発に使ってる10サイトで9割というのは機械学習で言う教師用データの精度と同じなので、実際はもう少し低くなります。

大まかな流れ

以下がアルゴリズムの大まかな流れです。

1 - ラベルと値を特定する 項目によって機械学習正規表現、または両方を使いラベルと値を特定します。ラベルが必要な項目とそうでない項目があります。

2 - レイヤーを重ねて候補を絞っていく 機械学習正規表現だけでは完全に値を特定出来ないので、様々な手法を使い正しい値を探します。

3 - 特定したXpathを保存する Xpathが特定出来たら、それをサイト毎に保存します。特定出来なかった項目は手動で書きます。

4 - 整形する Xpathを使って取ってきたものを整形します。

HTML elementのXpathを取得するプログラム

Pythonのlxmlを使ってHTMLのマニュピレーションをしているんですが、elementからXPathを自動的に取る関数がないので自分で書きました。(gistはこちら) XPathを使ったことがある人はご存知だと思いますが、XPathは何通りもの書き方があり、質の良いXPathと質の悪いXPathがあります。indexだと壊れる可能性が高いので、なるべくidやclassを使うようにします。

from urllib import request
from lxml import etree
import re

def get_index(e):
    tag = e.tag
    prev_list = [i for i in e.itersiblings(preceding=True) if i.tag == tag]
    next_list = [i for i in e.itersiblings() if i.tag == tag]
    if len(prev_list + next_list) == 0:
        return None
    return len(prev_list) + 1

def is_valid_class(c, siblings):    
    if re.search(r'[0-9]', c):
        return False
    c = c.strip()
    for sibling in siblings:
        if c in sibling:
            return False
    return True

def get_one_path(e):
    index = get_index(e)
    index = "[%s]" % (index) if index else ""
    this_attrib = e.attrib
    if 'id' in this_attrib:
        val = this_attrib['id']
        if not re.search(r'[0-9]', val):
            return e.tag + "[@id='%s']" % (val)
    if 'class' in this_attrib:
        ## 同じタグで同じクラスのものがsiblingにない場合のみclassを使用
        tag = e.tag
        prev_list = [i for i in e.itersiblings(preceding=True) if i.tag == tag and 'class' in i.attrib]
        next_list = [i for i in e.itersiblings() if i.tag == tag and 'class' in i.attrib]
        siblings = [e.attrib['class'].split(' ') for e in prev_list + next_list]
        class_list = this_attrib['class'].split(' ')
        for c in class_list:
            if is_valid_class(c, siblings):
                return e.tag + "[contains(concat(' ', normalize-space(@class), ' '), ' %s ')]" % (c)
    return e.tag + index

def get_xpath(e):
    my_xpath = ''
    while True:
        path = get_one_path(e)
        my_xpath = "/%s%s" % (path, my_xpath)
        e = e.getparent()
        # root tagまでたどり着いた
        if e is None:
            return my_xpath

url = 'https://rent.tokyu-housing-lease.co.jp/rent/8016671/6337'
def main():
    with request.urlopen(url) as f:
        data = f.read().decode('utf-8')     
        tree = etree.HTML(data)
        given_path = "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='article']/div[@id='item_detail']/div/table[contains(concat(' ', normalize-space(@class), ' '), ' item_table ')]/tr[5]/th[1]"
        p = tree.xpath(given_path)[0]
        print(get_xpath(p) == given_path)

if __name__ == '__main__':
    main()

1. ラベルと値を特定する。

第1ステップは、機械学習正規表現を使ってラベルと値を特定します。その際に1サイトにつき200物件ほどのデータを使います。

特定したら、そのelementのXPathを先ほどのget_xpathで取得し、テキストと同時に下記のようなフォーマットで保存します。

以下は賃料の値の例です。2物件だけ表示しています。1つ目のリストに4つリストが入っています。これは1つ目の物件で賃料と思われるテキストを含むHTML elementが4つあることを示しています。

[
  [
    [
      "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='article']/div[@id='item_detail']/div/table[contains(concat(' ', normalize-space(@class), ' '), ' item_table ')]/tr[5]/td[1]",
      "7.5万円"
    ],
    [
      "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='article']/div[@id='item_detail']/div/table[contains(concat(' ', normalize-space(@class), ' '), ' item_table ')]/tr[5]/td[2]",
      "0.3万円"
    ],
    [
      "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='aside']/div[contains(concat(' ', normalize-space(@class), ' '), ' side_fix_area ')]/dl[contains(concat(' ', normalize-space(@class), ' '), ' rent_view_side_info ')]/dd[7]",
      "7.5万円"
    ],
    [
      "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='aside']/div[contains(concat(' ', normalize-space(@class), ' '), ' side_fix_area ')]/div[@id='side_same_mansions']/div[contains(concat(' ', normalize-space(@class), ' '), ' module_body ')]/div[@id='other_room_table_wrap']/table[@id='other_room_table']/tbody/tr[contains(concat(' ', normalize-space(@class), ' '), '  ')]/td[contains(concat(' ', normalize-space(@class), ' '), ' tail ')]",
      "7.5万円"
    ]
  ],
  [
    [
      "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='article']/div[@id='item_detail']/div/table[contains(concat(' ', normalize-space(@class), ' '), ' item_table ')]/tr[5]/td[1]",
      "8.2万円"
    ],
    [
      "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='article']/div[@id='item_detail']/div/table[contains(concat(' ', normalize-space(@class), ' '), ' item_table ')]/tr[5]/td[2]",
      "0.3万円"
    ],
    [
      "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='aside']/div[contains(concat(' ', normalize-space(@class), ' '), ' side_fix_area ')]/dl[contains(concat(' ', normalize-space(@class), ' '), ' rent_view_side_info ')]/dd[7]",
      "8.2万円〜8.3万円"
    ],
    [
      "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='aside']/div[contains(concat(' ', normalize-space(@class), ' '), ' side_fix_area ')]/div[@id='side_same_mansions']/div[contains(concat(' ', normalize-space(@class), ' '), ' module_body ')]/div[@id='other_room_table_wrap']/table[@id='other_room_table']/tbody/tr[1]/td[contains(concat(' ', normalize-space(@class), ' '), ' tail ')]",
      "8.2万円"
    ],
    [
      "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='aside']/div[contains(concat(' ', normalize-space(@class), ' '), ' side_fix_area ')]/div[@id='side_same_mansions']/div[contains(concat(' ', normalize-space(@class), ' '), ' module_body ')]/div[@id='other_room_table_wrap']/table[@id='other_room_table']/tbody/tr[2]/td[contains(concat(' ', normalize-space(@class), ' '), ' tail ')]",
      "8.2万円"
    ],
    [
      "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='aside']/div[contains(concat(' ', normalize-space(@class), ' '), ' side_fix_area ')]/div[@id='side_same_mansions']/div[contains(concat(' ', normalize-space(@class), ' '), ' module_body ')]/div[@id='other_room_table_wrap']/table[@id='other_room_table']/tbody/tr[3]/td[contains(concat(' ', normalize-space(@class), ' '), ' tail ')]",
      "8.3万円"
    ]
  ]
]

ご覧の通り1つに絞れていないので、他の手法を使う必要があります。

2. レイヤーを重ねて候補を絞っていく

第2ステップがメインのアルゴリズムになります。第1ステップで特定したものを更に絞っていくのですが、手法が沢山あり、それらをレイヤーと呼んでいます。

項目によって色んなレイヤーを使い分けます。全て説明しようとすると一つのレイヤーで記事が一つ書けてしまうほどなので、よく使うレイヤーの概要だけ説明します。

全物件に共通してるものを除外する

ヘッダー、フッターなど、サイトで共通してるものは物件の情報でないので除外します。

項目毎にスレッショルドを決めています。例えばスレッショルドが.9で200物件使っていたら、180物件に存在していれば共通のものと判断します。何故こんなことをするかというと、共通情報がたまに一部のページにだけなかったりするので、少し余裕を持たせています。

リピートしてるものを除外する

物件詳細ページには、その物件以外にも、似ている物件や同じ建物の空室一覧が載っています。それらの物件は大抵複数載っているので、リピートしてるかどうかを見ます。

以下は住所の例です。また2物件だけ表示しています。ご覧の通り、2〜4つ目のXPathdivのindex以外同じなのが分かります。これらはメイン物件の情報ではないので除外します。

[
  [
    [
      "/html/body/div[@id='container']/div[@id='contents']/div[contains(concat(' ', normalize-space(@class), ' '), ' mainContent ')]/table[contains(concat(' ', normalize-space(@class), ' '), ' bottom ')]/tr[1]/th[contains(concat(' ', normalize-space(@class), ' '), ' address ')]",
      "所在地"
    ],
    [
      "/html/body/div[@id='container']/div[@id='contents']/div[contains(concat(' ', normalize-space(@class), ' '), ' mainContent ')]/div[contains(concat(' ', normalize-space(@class), ' '), ' nearBox ')]/div[1]/div[contains(concat(' ', normalize-space(@class), ' '), ' nearInner ')]/ul[contains(concat(' ', normalize-space(@class), ' '), ' detail_info ')]/li[3]/dl[contains(concat(' ', normalize-space(@class), ' '), ' clearfix ')]/dt[contains(concat(' ', normalize-space(@class), ' '), ' address ')]",
      "住所"
    ],
    [
      "/html/body/div[@id='container']/div[@id='contents']/div[contains(concat(' ', normalize-space(@class), ' '), ' mainContent ')]/div[contains(concat(' ', normalize-space(@class), ' '), ' nearBox ')]/div[2]/div[contains(concat(' ', normalize-space(@class), ' '), ' nearInner ')]/ul[contains(concat(' ', normalize-space(@class), ' '), ' detail_info ')]/li[3]/dl[contains(concat(' ', normalize-space(@class), ' '), ' clearfix ')]/dt[contains(concat(' ', normalize-space(@class), ' '), ' address ')]",
      "住所"
    ],
    [
      "/html/body/div[@id='container']/div[@id='contents']/div[contains(concat(' ', normalize-space(@class), ' '), ' mainContent ')]/div[contains(concat(' ', normalize-space(@class), ' '), ' nearBox ')]/div[3]/div[contains(concat(' ', normalize-space(@class), ' '), ' nearInner ')]/ul[contains(concat(' ', normalize-space(@class), ' '), ' detail_info ')]/li[3]/dl[contains(concat(' ', normalize-space(@class), ' '), ' clearfix ')]/dt[contains(concat(' ', normalize-space(@class), ' '), ' address ')]",
      "住所"
    ]
  ],
  [
    [
      "/html/body/div[@id='container']/div[@id='contents']/div[contains(concat(' ', normalize-space(@class), ' '), ' mainContent ')]/table[contains(concat(' ', normalize-space(@class), ' '), ' bottom ')]/tr[1]/th[contains(concat(' ', normalize-space(@class), ' '), ' address ')]",
      "所在地"
    ],
    [
      "/html/body/div[@id='container']/div[@id='contents']/div[contains(concat(' ', normalize-space(@class), ' '), ' mainContent ')]/div[contains(concat(' ', normalize-space(@class), ' '), ' nearBox ')]/div[1]/div[contains(concat(' ', normalize-space(@class), ' '), ' nearInner ')]/ul[contains(concat(' ', normalize-space(@class), ' '), ' detail_info ')]/li[3]/dl[contains(concat(' ', normalize-space(@class), ' '), ' clearfix ')]/dt[contains(concat(' ', normalize-space(@class), ' '), ' address ')]",
      "住所"
    ],
    [
      "/html/body/div[@id='container']/div[@id='contents']/div[contains(concat(' ', normalize-space(@class), ' '), ' mainContent ')]/div[contains(concat(' ', normalize-space(@class), ' '), ' nearBox ')]/div[2]/div[contains(concat(' ', normalize-space(@class), ' '), ' nearInner ')]/ul[contains(concat(' ', normalize-space(@class), ' '), ' detail_info ')]/li[3]/dl[contains(concat(' ', normalize-space(@class), ' '), ' clearfix ')]/dt[contains(concat(' ', normalize-space(@class), ' '), ' address ')]",
      "住所"
    ],
    [
      "/html/body/div[@id='container']/div[@id='contents']/div[contains(concat(' ', normalize-space(@class), ' '), ' mainContent ')]/div[contains(concat(' ', normalize-space(@class), ' '), ' nearBox ')]/div[3]/div[contains(concat(' ', normalize-space(@class), ' '), ' nearInner ')]/ul[contains(concat(' ', normalize-space(@class), ' '), ' detail_info ')]/li[3]/dl[contains(concat(' ', normalize-space(@class), ' '), ' clearfix ')]/dt[contains(concat(' ', normalize-space(@class), ' '), ' address ')]",
      "住所"
    ]
  ]
]

ラベルに近いものを採用する

特定したラベルを使い、そのラベルに一番近い値を採用します。この「近さ」を計るには、ラベルのXPathと値のXPathがどれだけ違うというのを計算します。

敷金や礼金など、ラベルがないと区別がつかないものには必須です。

ポピュラーなものを採用する

最後の方の工程で使われます。それぞれのXpathがどれくらいのページに使われてるかを計算し、一番使われているものを採用します。ノイズを削除するのに使います。

最初に出てきたものを採用する

これも最後の方の工程で使います。 それぞれのページで一番最初に出てきたXpathを採用するというシンプルな手法ですが、中々使えます。他のレイヤーである程度絞っていれば、後は先に出てきたものがメイン物件の情報である可能性が高いです。

3 - 特定したXpathを保存する

第2ステップでレイヤーを特定したら、サイト毎にjsonファイルにまとめます。その際に必要なメタデータを自動で取得します。

以下はWinsproというサイトの例です。設備・条件は読点で区切られてるので、それを保存しておきます。敷金、礼金、保証金の場合は同じelementに複数の項目が入ってるので、ポジションを保存しておきます。

{
  "option": {
    "label": "設備・条件",
    "delimiter": "、",
    "path": [
      "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='article']/div[@id='item_detail']/div[contains(concat(' ', normalize-space(@class), ' '), '  ')]/table[contains(concat(' ', normalize-space(@class), ' '), ' item_table ')]/tr/th[text()='設備・条件']/following-sibling::td"
    ]
  },
  "shikikin": {
    "label": "敷金/礼金",
    "position": 0,
    "path": "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='article']/div[@id='item_info']/table[contains(concat(' ', normalize-space(@class), ' '), ' item_table ')]/tr/th[1][text()='敷金/礼金']/following-sibling::td[1]"
  },
  "reikin": {
    "label": "敷金/礼金",
    "position": 1,
    "path": "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='article']/div[@id='item_info']/table[contains(concat(' ', normalize-space(@class), ' '), ' item_table ')]/tr/th[1][text()='敷金/礼金']/following-sibling::td[1]"
  },
  "hosyokin": {
    "label": "敷金/保証金",
    "position": 1,
    "path": "/html/body[@id='diamondtail']/div[@id='wrap']/div[@id='contents_wrap']/div[@id='contents']/div[@id='contents_inner']/div[@id='article']/div[@id='item_detail']/div[contains(concat(' ', normalize-space(@class), ' '), '  ')]/table[contains(concat(' ', normalize-space(@class), ' '), ' item_table ')]/tr/th[1][text()='敷金/保証金']/following-sibling::td[1]"
  }
}

4. 整形する

第3ステップで作ったデータを使ってスクレイピングと整形をします。Scrapyを使っていますが、デフォルトのParserではなくlxmlを使っています。

まとめ

今回ご紹介した方法は、不動産サイトのように項目が主にテーブルの中に入ってるようなものに向いています。ブログ記事のような長いテキストの中から情報を抽出するのであれば自然言語処理を使った方が良いでしょう。

もし汎用的クローラーを作ってる方がいらっしゃったら是非情報交換しましょう!

エンジニアの採用も強化しておりますので気軽にオフィスに遊びに来て下さい!