Lineでよさそうな部屋を紹介してくれるボットを作成する

# 借りる部屋を簡単に探したい
部屋を借りるときにSuumoなどのサイトを使って部屋を検索することができる。しかし、以下の理由により既存のサイトでは使いにくさを感じた。
・同じ物件を複数の不動産屋が掲載していて省きたい
・検索結果の情報量が多いので必要な情報だけを一覧で確認したい
・検索条件がある程度固定されている中（１人暮らし、家族向けなど）で物件同士を比較したい
・電車の中などで短時間で手軽に物件検索したい

そこで、これらの課題を解決するアプリを開発した。

# Lineで簡単に良さそうな物件を紹介してくれるボットの作成
Suumoから自分の探したい条件の物件を簡単に比較できるようなLineボットを作成した。
自分の探したい物件の情報としては、「神奈川県の北東寄り」「間取りは2LDK以上」、「家賃は15万円以下」などがあり、こちらに該当する物件が今回借りたい部屋の条件である。
また、物件同士で比較したい条件は「家賃」、「敷金/礼金」、「間取り」などがある。
詳しい検索条件、比較内容については以降で記載する。
以下完成したLineボットで物件について検索している画面である。
駅名や地域などのキーワードを入力すると、あらかじめ指定した条件に合致する物件の中からキーワードに関連する物件を抽出して応答メッセージとして返信され、簡単に比較することができる。

<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2517503/c4bc8b50-6fcb-c48f-17fe-68920e3f0ec8.png" />
<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2517503/450346e7-5a7e-a89f-e293-ef4f04b88353.png"/>


# 構成
システムはAWS上に構築した。
構成は非常にシンプルで、LambdaとDynamoDBでSuumoサイトのスクレイピングとAPI経由の検索処理を実装し、API GatewayでLineボットのWebhookと、ブラウザからのRestAPI要求のAPIを作った。

![aws.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2517503/50f00c35-f43c-1aee-e02d-82cc44f3eea9.png)

AWSで以下リソースを作成する。
- Lambda（関数）
    - suumo_notif_scraper
    - suumo_linebot
    - suumo_notif_search
- DynamoDB（テーブル）
    - suumoRoomRealtime : パーティンションキーname
- API Gateway（API）
    - /linebot
        - POST : LineボットのWebhook応答
    - /search
        - GET : 物件検索API
        - POST : 物件検索API

# スクレイピング
Suumoのページを定期的にスクレイピングし、検索条件に当てはまる物件をDBに格納する。
Suumoのページ（https://suumo.jp ）は検索条件をGETクエリパラメータで指定できるので、こちらを指定することでページに表示される物件を絞ることができる。

主なクエリパラメータには以下のようなものがある。各パラメータについては、実際のサイトで条件を変えて検索してみることで簡単に確認することができる。
- ar: エリア (030=神奈川県)
- sc: 県内のさらに詳細なエリア（市区町村など）
- cb: 下限家賃
- ct: 上限家賃
- et: 駅からの徒歩圏内分（以内）
- md: 間取りタイプ（07=2LDK,10=3LDK）
- cn: 築後年数（9999999=指定しない）
- mb: 専有面積下限
- mt: 専有面積上限

(神奈川県内のさらに詳細なエリア番号の例)
- 14101:横浜市鶴見区(24,791)
- 14102:横浜市神奈川区(26,594)
- 14103:横浜市西区(13,326)
- 14104:横浜市中区(18,654)
- 14131:川崎市川崎区(19,348)
- 14132:川崎市幸区(11,102)
- 14133:川崎市中原区(27,975)

スクレイピングで取得する物件情報としては、
- 家賃、管理費
- 敷金、礼金
- 間取り
- 面積
- 駅からの距離
- 何階か
- 築年数、建物の階数
- 契約（普通賃貸など）

の情報を定期的に取得し、これらの情報をDynamoDBに格納する。

今回はpythonでLambda関数を記述する。依存パッケージを利用する際の注意点としては、依存パッケージ本体を含めたzipファイルをアップロードする必要がある。
まずローカルに、Lambda関数で実行したいファイル（今回はsuumo_notif_scraper.py）を含むフォルダを作成し、そのフォルダ内で各依存パッケージに対して以下コマンドを実行する。
以下はrequestsパッケージの例。
```
pip install requests -t .
```
すると、フォルダ内にパッケージ本体がダウンロードされるのでこれらをzipファイルにまとめる。
```
zip -r function.zip *
```
そしてこのzipファイルをLambda関数としてアップロードする。
Lambda関数の呼び出しのトリガーとしてEventBridgeを設定することで、定期的にLambda関数を実行することができる。
今回はcron式を使って毎日1,9,17時(UTC)にスクレイピングが実行されるようにした。
（cron式はAWS独特のクセがある為公式を確認することを推奨: [AWSサイト](https://docs.aws.amazon.com/ja_jp/eventbridge/latest/userguide/eb-cron-expressions.html))

```
* 1,9,17 ? * * *
```

以下実際のコードである。(suumo_notif_scraper.py)
検索結果ページを１０ページ分スクレイピングして物件情報を取得している。

```python
import json
import requests
from bs4 import BeautifulSoup
import urllib
import datetime
import boto3
from decimal import Decimal

MAX_PAGE = 10

suumo_url = "https://suumo.jp/jj/chintai/ichiran/FR301FC001/?ar={ar}&bs=040&ta=14&{sc}&cb={cb}&ct={ct}&et={et}&{md}&cn={cn}&mb={mb}&mt={mt}&shkr1=03&shkr2=03&shkr3=03&shkr4=03&fw2=&page={page}"
suumo_detail_url = "https://suumo.jp"

def load_soup(url):
    html = requests.get(url)
    soup = BeautifulSoup(html.content, "html.parser")
    return soup

def construct_suumo_url(url, page, options):
    ar = "030"
    sc = ["14101","14102","14103","14104","14105","14106","14109","14113","14117","14118","14133"]
    cb = "10.0"
    ct = "16.0"
    et = "10"
    md = ["07","10"]
    cn = "20"
    mb = "50"
    mt = "9999999"

    sc = "sc=" + "&sc=".join(sc)
    md = "md=" + "&md=".join(md)

    url = url.format(ar=ar,sc=sc,cb=cb,ct=ct,et=et,md=md,cn=cn,mb=mb,mt=mt,page=page)  
    return url

def fetch_suumo_data(MAX_PAGE = MAX_PAGE):
    cards = []
    for idx in range(1,MAX_PAGE):
        url = construct_suumo_url(suumo_url, idx, {})
        soup = load_soup(url)
        _cards = soup.find_all(class_='cassetteitem')
        if len(_cards) == 0:
            break
        cards += _cards

    cur_date_str = datetime.datetime.now().strftime('%Y%m%d%H%M%S')
    rooms = []
    for card in cards:
        price_str = card.find(class_="cassetteitem_price cassetteitem_price--rent").text.split("万円")[0].split("円")[0]
        price_admin_str = card.find(class_="cassetteitem_price cassetteitem_price--administration").text.split("万円")[0].split("円")[0]
        size_str = card.find(class_="cassetteitem_menseki").text.split("m2")[0]
        depo_str = card.find(class_="cassetteitem_price cassetteitem_price--deposit").text.split("万円")[0].split("円")[0]
        grat_str = card.find(class_="cassetteitem_price cassetteitem_price--gratuity").text.split("万円")[0].split("円")[0]
        if depo_str == "-":
            depo_str = "0"
        if price_admin_str == "-":
            price_admin_str = "0"
        if grat_str == "-":
            grat_str = "0"

        room_info = {
            "name": card.find(class_="cassetteitem_content-title").text,
            "price": float(price_str),
            "price_admin": float(price_admin_str),
            "type": card.find(class_="ui-pct ui-pct--util1").text,
            "madori": card.find(class_="cassetteitem_madori").text,
            "size": float(size_str),
            "deposit": float(depo_str),
            "gratuity": float(grat_str),
            "address": card.find(class_="cassetteitem_detail-col1").text,
            "stations": ",".join([c.text for c in card.find_all(class_="cassetteitem_detail-text")]),
            "floor": card.find(class_="js-cassette_link").find_all("td")[2].text,
            "info": ",".join([c.text for c in card.find(class_="cassetteitem_detail-col3").find_all("div")]),
            "link": suumo_detail_url + card.find(class_="js-cassette_link_href cassetteitem_other-linktext")["href"],
            "date": int(cur_date_str),
        }
        for key in room_info:
            if type(room_info[key]) is str:
                for sym in ["\n","\r","\t"]:
                    room_info[key] = "".join(room_info[key].split(sym))
        rooms.append(room_info)

    # delete duplication
    room_dict = {}
    for room in rooms:
        room_dict[room["name"]] = room
    _rooms = []
    for name in room_dict:
        _rooms.append(room_dict[name])
    rooms = _rooms

    return rooms

def area_filter(rooms, areas = []):
    new_rooms = []
    for room in rooms:
        isInArea = len(areas) == 0
        for area in areas:
            isInArea = isInArea or (len(room["stations"].split(area)) > 1)
        
        if isInArea:
            new_rooms.append(room)
    return new_rooms

def lambda_handler(event, context):
    areas = []

    dynamodb = boto3.resource('dynamodb')
    table = dynamodb.Table("suumoRoomRealtime")

    rooms = fetch_suumo_data(MAX_PAGE)
    rooms = area_filter(rooms, areas)

    # parse float to decimal
    rooms = json.loads(json.dumps(rooms), parse_float=Decimal)

    with table.batch_writer() as batch:
        for room in rooms:
            batch.put_item(Item=room)

    return {
        'statusCode': 200,
        'body': "ok",
    }

```

Lambda関数を作成できたらDynamoDBへのアクセス権限を実行ロールに付与し、動くことを確認する。

# Lineボットの作成
Lineボット(Messaging API)は、Lineアプリ内でメッセージを送るとボットが投稿に対して自動で返信などのアクションを実行してくれる機能である。
Lineボットを実装するためには、Lineのメッセージを受信して応答等のアクションを行えるようなAPIを公開し、Line Developer画面からWebhook呼び出し先として公開したAPIのURLを指定する。
Line Developer画面で適当なプロバイダー、チャネルを作成し、「Messaging API設定」→「Webhookの利用」を許可し、後でAPIを作成したらWebhook URLを設定する。
他の機能については必要に応じて適宜設定し、最後にLine DeveloperページのQRコードからLineの友達としてボットを追加し利用を開始する。

Lineボットの機能としては以下の機能を実装することとした。
・「最寄り駅名」、「地域名」などのキーワードを入力すると、そのキーワードに関係する物件をDynamoDBから検索して最大５件応答する。
・同じ物件と思われるものを除外する。ただし、「家賃」＋「管理費」＋「面積」が完全に同一のものを同一の物件と定義した。
・キーワードの最後に「all」をつけると結果を要約して１つのメッセージで応答する。
・「説明」とメッセージを送ると検索条件の簡単な説明が確認できる。

以下は実際のコードとなる。（suumo_linebot.py）

```python
import os
import json
import datetime
from decimal import Decimal
import random
import boto3
from boto3.dynamodb.conditions import Key, Attr
import logging
from linebot.v3 import (
    WebhookHandler
)
from linebot.v3.exceptions import (
    InvalidSignatureError
)
from linebot.v3.messaging import (
    Configuration,
    ApiClient,
    MessagingApi,
    ReplyMessageRequest,
    TextMessage,
    FlexMessage,
    FlexBubble,
    FlexSeparator,
    FlexBox,
    FlexText,
    URIAction,
)
from linebot.v3.webhooks import (
    MessageEvent,
    TextMessageContent
)

MAX_ROOM_COUNT = 5

logger = logging.getLogger()
logger.setLevel(logging.ERROR)

channel_access_token = os.getenv('LINE_CHANNEL_ACCESS_TOKEN')
channel_secret = os.getenv('LINE_CHANNEL_SECRET')

configuration = Configuration(access_token=channel_access_token)
api_client = ApiClient(configuration) 
line_bot_api = MessagingApi(api_client)
handler = WebhookHandler(channel_secret)

def decimal_to_int(obj):
    if isinstance(obj, Decimal):
        return int(obj)

def get_dynamo_roomdata(keyword, isAll):
    dynamoDB = boto3.resource("dynamodb")
    table = dynamoDB.Table("suumoRoomRealtime")
    
    queryData = table.scan(FilterExpression = Attr("name").contains(keyword) | Attr("address").contains(keyword) | Attr("stations").contains(keyword))
    rooms = queryData["Items"]

    # select recent data (in 2 weeks)
    last_date = datetime.datetime.now() - datetime.timedelta(days=14)
    last_date_str = last_date.strftime('%Y%m%d%H%M%S')
    rooms = list(filter(lambda r: r["date"] >= int(last_date_str), rooms))

    # delete duplication
    price_size_dict = {}
    for room in rooms:
        key = room["price"] + room["size"]
        price_size_dict[key] = room
    _rooms = []
    for key in price_size_dict:
        _rooms.append(price_size_dict[key])
    rooms = _rooms
    
    random.shuffle(rooms)
    if not isAll:
        rooms = rooms[:MAX_ROOM_COUNT]
    return rooms

def lambda_handler(event, context):
    signature = event["headers"]["x-line-signature"]
    body = event["body"]
    
    @handler.add(MessageEvent, message=TextMessageContent)
    def handle_message(event):
        usermsg = event.message.text

        botmsg = [TextMessage(text="[応答なし]")]
        if usermsg == "説明":
            res = "suumoの神奈川付近の物件情報を確認します。\n"
            res += "デフォルトの条件は以下です。\n"
            res += "神奈川県の北東部\n"
            res += "家賃は 10万円以上 16万円以下\n"
            res += "駅から徒歩10分以内\n"
            res += "間取り: 2LDK, 3LDK\n"
            res += "部屋の大きさは 55m2以上\n"
            res += "\n"
            res += "キーワードを入力するとキーワードに関係する物件が検索されます。\n"
            res += "最大５件の物件がランダムに返ってきます。\n"
            res += "最後にallを指定するとすべての結果一覧が返ってきます。"
            botmsg = [TextMessage(text=res)]
        elif len(usermsg.split("all")) > 1:
            keyword = usermsg.split("all")[0]
            rooms = get_dynamo_roomdata(keyword, True)
            container = FlexBubble()
            container.header = FlexBox(
                layout='vertical',
                contents=[FlexText(text="すべての物件(" + keyword + ")", color='#FFFFFF')],
                spacing='sm',
                backgroundColor='#ED1A3D',
                paddingAll='xxl'
            )
            container.body = FlexBox(
                layout='vertical',
                contents=[],
            )
            ridx = 1
            for room in rooms:
                text = "[" + str(ridx) + "] " + str(Decimal(room["price"])) + "万円 (" + str(Decimal(room["price_admin"])) + "円), "
                text += str(Decimal(room["deposit"])) + "万円/" + str(Decimal(room["gratuity"])) + "万円, "
                text += str(Decimal(room["size"])) + "m2, "
                text += room["madori"] + ", "
                text += room["type"]
                container.body.contents.append(FlexText(text=text, wrap = True, action=URIAction(uri=room["link"])))
                ridx += 1
            botmsg = [FlexMessage(altText = "(all)" + keyword, contents = container)]
        else:
            rooms = get_dynamo_roomdata(usermsg, False)
            botmsg = []
            for room in rooms:
                container = FlexBubble()
                container.header = FlexBox(
                    layout='vertical',
                    contents=[FlexText(text=room["name"], color='#FFFFFF')],
                    spacing='sm',
                    backgroundColor='#0367D3',
                    paddingAll='xxl'
                )
                container.body = FlexBox(
                    layout='vertical',
                    contents=[],
                    action=URIAction(uri=room["link"]),
                )
                text = "家賃:" + str(Decimal(room["price"])) + "万円 (" + str(Decimal(room["price_admin"])) + "円)"
                container.body.contents.append(FlexText(text=text))
                text = "敷金/礼金:" + str(Decimal(room["deposit"])) + "万円/" + str(Decimal(room["gratuity"])) + "万円"
                container.body.contents.append(FlexText(text=text))
                text = "広さ:" + str(Decimal(room["size"])) + "m2"
                container.body.contents.append(FlexText(text=text))
                text = "間取り:" + room["madori"]
                container.body.contents.append(FlexText(text=text))
                text = "最寄り駅:" + room["stations"]
                container.body.contents.append(FlexText(text=text, wrap = True))
                text = "契約:" + room["type"]
                container.body.contents.append(FlexText(text=text))
                text = "階数:" + room["floor"]
                container.body.contents.append(FlexText(text=text))
                botmsg.append(FlexMessage(altText = "size: " + str(len(rooms)), contents = container))

        line_bot_api.reply_message_with_http_info(
            ReplyMessageRequest(
                reply_token=event.reply_token,
                messages=botmsg
            )
        )

    try:
        handler.handle(body, signature)
    except InvalidSignatureError:
        message = "Invalid signature. Please check your channel access token/channel secret."
        logger.error(message)
        return {'statusCode': 400,'body': message}
    
    return {'statusCode': 200, 'body': "OK"}
```

こちらをスクレイピングのコードと同様Lambdaにアップロードする。
そしてAPI GatewayでLambdaにリクエストが連携されるようAPIを作成して設定する。
API Gatewayの設定の注意点としては、POSTリクエストで統合リクエストのLambda プロキシ統合をオンにする必要がある。
同様にRestAPIについても作成したが、RestAPIは渡されたキーワードを元にDynamoDBから該当する物件を抽出してくるだけの機能のためここでは割愛する。
こちらのRestAPI機能を使ってブラウザから直接物件情報を引っ張ってくることもできる。

# 実際に利用して部屋を探してみる
実際にこちらのボットで空き時間に物件を探してみると、通勤等に便利な駅の徒歩圏内で比較したい情報のみを使って物件同士を簡単に比較することができ、中には広さや立地の割に家賃が安い掘り出し物物件も見つかった。
また、毎日情報が更新され簡単に他の部屋と比較しながら確認できるので、すぐになくなってしまうような優良な部屋についても簡単に見つけられた。
そろそろ引越しを考えているので、このアプリを利用して良い部屋を見つけたい。