Pythonで中古マンション探索最適化 ~LINE通知編~
こんにちは、やぐちはるおです。最近中古マンションの購入を検討しています。ウチの親もそうでしたが、物件情報って無限に探し続けられますよね。あれって物件見てるのが楽しいってのもありますが、
「これ前も見た物件だわ……」
というのがめちゃくちゃ多いですよね。もうちょっと効率的に探せればQoL上がるかなと思って今回、Pythonに中古マンションを探させ、情報が更新された物件を見つけたらLINEに通知させる仕組みを作りました。(タイトルで数理最適化だと思った人ごめんなさい)
ソースコードはこちらです。よかったらReadme見ながら動かしてみてください。あまり他人にコードを見られたことがないので、書き方で気になるところとかあれば何でもコメントほしいです。
実際のLINE画面
今のところ新着、削除、価格変動を通知させるようにしています。Windows PCでタスクスケジューラ使って毎日2回、4時半と15時に起動しています。あまり突っ込んで話しませんが、もし動かす人がいたらこの時間にしとくといいです。
LINE通知させる手順とコード解説
手順は以下のとおりです。
わぁ、すごい簡単!
- 古い物件情報を読み込む
- SUUMOから情報を取得して読み込む
- 各差分チェックとLINE通知
単純なスクレイピングと愚直なデータ処理ですが
コードの中身をゆっくり解説していきます。
1. 古い物件情報を読み込む
まずは、すでに取得した物件情報を読み込みます。今回の実装では、Pythonを実行すると物件情報がCSVで出力されるようになっています。ただ、複数回実行しても最新の物件情報しか残らないようになっているため、先に既存の物件情報を読み込んでおきます。ここは要改善点か。
# 物件情報ファイルを読み込む、無ければ作りLINE通知はしない
line_notify = True
if not os.path.exists(config['data']):
line_notify = False
with open(config['data'], 'w') as f:
writer = csv.writer(f)
writer.writerow(config['header'])
df_old = pd.read_csv(config['data'])
ここでline_notify
という変数がありますが、これは名前の通りLINE通知させるかどうかを制御する変数です。ってかis_notify
とかのほうが良さそうだな。もし既存の物件情報がない場合、新着物件3万件とかになりかねません。さすがにそれを通知したらLINE壊れちゃうので、初めて情報を取得する際にはLINE通知させないようにしています。
また、ここ以降でもコード中にconfig['hoge']
が出てきますが、これには設定ファイルの値が入っています。このコードよりも前に読み込ませていますが端折りました。
最後にdf_oldで古い物件情報(CSVファイル)を読み込んでいます。差分チェック後にこの変数を使うので覚えておいてください。
2. SUUMOから情報を取得して読み込む
今更ですが、SUUMOからは中古マンションで検索結果ページで見えてるデータだけ取得しています。具体的に言うと画像の赤枠部分です。
取得情報に会社名を入れるか少し迷いましたが、たまに嘘の物件情報が載っている(特に築年数をリフォームした年にしているのが多い)ので、そういう情報を載せた会社をブラックリスト入りするために取得しています。今回のプログラムにそういう機能はありません。いつか追加してもいいかもしれない。
そして実際に取得したCSVがこちら↓
※ 横長すぎて見切れてます。
ヘッダ部分にある通り、以下の情報を取得しています。
id:SUUMO掲載物件ID、URLとかに書いてある
name:物件名。目立たせるために不動産やがラノベみたいな名前にしている
url:URL
price:価格
location:住所
station:最寄り駅と物件からかかる時間
area:部屋面積
floor plan:間取り
balcony:バルコニー面積
data of construction:建物完成年月 # dateのスペルミスってる
company:不動産屋
ではコード解説に入ります。
生の検索結果ページを取得
スクレイピングには、皆さん大好きBeautifulSoupを使用しています。まず最初に検索結果ページで生のHTMLファイルを取得します。今回欲しい情報はbodyタグの中にあるので、bodyだけ抽出します。
# 検索結果ページのHTMLからbodyを抽出
result = requests.get(config["result_url"])
soup = BeautifulSoup(result.content, 'html5lib')
body = soup.find('body')
検索結果ページ数を取得
bodyが取得できたら、次に検索結果が何ページあるかを取得します。
# 検索結果のページ数を取得
pagenation = body.find('div', {'class': 'pagination pagination_set-nav'})
li = pagenation.find_all('li')[-1]
page_num = int(li.find('a').text) if li.find('a') else 1
page_num
にページ数を格納していますが、1ページしか無いときは上の画像の750みたいな数値がありません。そのため、条件分岐を入れて、ページ数の数値が見つからないときは1を格納しています。
物件情報を取得してリスト化してDataFrame化
ここでようやく物件情報をデータとして格納していきます。
# 検索結果1ページ目の物件データ収集
properties = list()
for property_unit in body.find_all('div', {'class': 'property_unit'}):
properties.append(get_property_data(property_unit))
# 2ページ目以降の各物件データ収集
for i in range(2, page_num + 1):
url = config["result_url"] + '&pn=' + str(i)
result = requests.get(url)
soup = BeautifulSoup(result.content, 'html5lib')
body = soup.find('body')
for property_unit in body.find_all('div', {'class': 'property_unit'}):
properties.append(get_property_data(property_unit))
# 取得した物件情報をデータフレームに変換してCSVに出力
df_new = pd.DataFrame(
data=properties,
columns=config['header']
)
df_new.to_csv(config['data'], index=False)
ちょっと長くなりましたね。
まず、ここでは検索結果の1ページ目と2ページ目以降で処理を分けています。これは、1ページ目と2ページ目以降で微妙にURLが異なっているためです。実際のURLがこちら↓
例) 関東の中古マンションの検索結果
1ページ目:https://suumo.jp/jj/bukken/ichiran/JJ010FJ001/?ar=030&bs=011
2ページ目:https://suumo.jp/jj/bukken/ichiran/JJ010FJ001/?ar=030&bs=011&pn=2
3ページ目:https://suumo.jp/jj/bukken/ichiran/JJ010FJ001/?ar=030&bs=011&pn=3
ご覧の通り、「&pn=数字」が追加されています。処理をわけずにいい感じの条件分岐を書いても良いんですが、多分下手なことするより意図を理解しやすそうなのでこうしました。
また、実際に物件情報を取得しているのが次の一行です
properties.append(get_property_data(property_unit))
ちょっとややこしいですが、get_property_data()
という関数を別で定義していて、ここで物件情報の抽出処理を行います。そして抽出した物件情報をproperties.append()
でリストに入れています。
最後にリスト化した情報をdf_new
にDataFrameで格納してCSVファイルに出力しています。リストからCSVに出力するのが面倒でしたので一旦DataFrame化しています。ここまでで、物件情報の取得は完了しました。
3. 各差分チェックとLINE通知
では最後に差分チェックとLINE通知をします。LINE通知にはLINE Notifyを使います。LINE Notifyの設定方法は冒頭のGithubのReadmeに書いてあるのでそちらを読んでください。関係ないですがなんか年明けくらいからやけにLINE通知させる投稿が流行ってましたね。なんで?
LINE通知させる関数
先にLINE通知させる関数send_line_notify()
を定義します。差分チェック後にこの関数を使うので覚えておいてください。
def send_line_notify(notification_message):
headers = {'Authorization': f'Bearer {config["line_notify_token"]}'}
data = {'message': f'{notification_message}'}
requests.post(config["line_notify_api"], headers=headers, data=data)
LINE NotifyのアクセストークンとURLは設定ファイルに記述してあります。引数で通知したいメッセージを受け取ったらそれをそのままLINEに流します。
新着物件を通知させる
まずは新着物件の通知です。
# 物件情報を確認して差分があればLINE通知
if line_notify:
list_old = df_old['id'].tolist()
list_new = df_new['id'].tolist()
# 追加物件
added_properties = set(list_new) - set(list_old)
if added_properties == set():
logger.info('NOT added from last time.')
else:
message = ''
for added_property in added_properties:
message += df_new.query('id == @added_property')['url'].values[0] + '\r\n'
send_line_notify('次の物件が追加されました\r\n' + message)
最初にline_notify
で差分チェック&通知処理を行うかどうか判断してます。この変数覚えてましたか?次に物件IDを新旧でリスト化してlist_old, list_new
に放り込んでいます。
そのあとset(list_new) - set(list_old)
で集合の引き算をしています。この演算で新しい物件情報だけに存在するIDを抽出することができます。集合って便利だけどあまり使われないデータ構造ですよね。IDが抽出できたらあとはそのIDに紐付いた物件情報を先程のLINE通知関数に入れてあげれば完了です。
削除物件を通知させる
次に削除物件です。すでにお察しの方もいるでしょう。追加と逆のことをします。
# 削除物件
reduced_properties = set(list_old) - set(list_new)
if reduced_properties == set():
logger.info('NOT reduced from last time.')
else:
message = ''
for reduced_property in reduced_properties:
message += str(df_old.query('id == @reduced_property').loc[:, ['name', 'price', 'location']].values[0]) + '\r\n'
send_line_notify('次の物件が削除されました\r\n' + message)
追加処理との違いわかるでしょうか。そう、集合の演算が逆になっています。
reduced_properties = set(list_old) - set(list_new)
これで古い方だけに存在する物件情報、つまり削除された情報を抽出できます。あとは以下略。
価格変更物件を通知させる
これが地味に面倒な処理でした。
# 価格変動
message_price = ''
for item_new in list_new:
item_old = df_old.query('id == @item_new')
if len(item_old) == 0:
continue
name_and_price_new = df_new.query('id == @item_new').loc[:, ['name', 'url', 'price']].values[0]
name_and_price_old = df_old.query('id == @item_new').loc[:, ['name', 'url', 'price']].values[0]
if name_and_price_new[2] != name_and_price_old[2]:
message_price += name_and_price_new[0] + ' '
message_price += name_and_price_old[2] + ' => ' + name_and_price_new[2] + '\r\n'
message_price += name_and_price_new[1] + '\r\n'
if message_price != '':
send_line_notify('次の物件価格が変更されました\r\n' + message_price)
処理としては、新しい物件情報を一つひとつ見ていき、古い物件情報と価格を比較します。価格が変更されていたらメッセージを追加していき、最後にまとめてLINE通知する処理となっています。
DataFrameのクエリを使って細かいデータのとり方をしているので、すこしゆっくりコードを読まないと理解しづらくなっています。てか書き方が悪いかしら……。新しい物件情報を見ていく際に、古い物件情報に同じIDのものがあるか見て、なければ次のイテレーションを見るようにしています。
item_old = df_old.query('id == @item_new')
if len(item_old) == 0:
continue
そのあと新旧の価格に差があればメッセージ用変数に価格変更の文字列を追加しています。
name_and_price_new = df_new.query('id == @item_new').loc[:, ['name', 'url', 'price']].values[0]
name_and_price_old = df_old.query('id == @item_new').loc[:, ['name', 'url', 'price']].values[0]
if name_and_price_new[2] != name_and_price_old[2]:
message_price += name_and_price_new[0] + ' '
message_price += name_and_price_old[2] + ' => ' + name_and_price_new[2] + '\r\n'
message_price += name_and_price_new[1] + '\r\n'
ここでは、新旧物件の名前、URL、価格だけを抽出・通知しています。他の情報は実際にスマホからアクセスすれば良いと思ったためです。実際3ヶ月位これで運用していますが、物件の名前見れば「あぁあの物件か」ってなるので全然問題ありません。
おわりに、次回予告 分析編
少し長くなってしまいましたが、とりあえず今できている部分を自分なりに文章にしました。これほど長い技術文書くことってなかなかないので疲れました。
今は通知するだけなんですが、これだけだと新着物件がお得なのかどうかが全然わかりません。主観的に安そうとか高そうとかふわっと思うくらいです。なので、今後はお得な物件かどうかを判定できるように、収集した物件情報を分析にかけていきます。
幸い、昨年末に素晴らしい先人がいらっしゃったので、その方を参考にしてやるだけやってみます。マンションレビューとかみたいにマンション相場がぱっと分かるようになりたいですね。
素晴らしい先人の記事↓
以上です。ここまで読んでくださってありがとうございます。何か物件探しでもコードでもアドバイスやコメント、質問等いただけると嬉しいです。
Discussion