東京の都心部、南部の10区について各住所の賃貸価格をmapにしてみた


動機

職場が移転し、今の家から遠くなるため、引っ越したい。しかしどのあたりの賃貸価格が安いのか、賃貸検索サイトなど見ても詳細な情報がない。駅別の賃貸価格が載っていたりするが別々のページに情報があり、統合して理解することが難しい。各地点での賃貸価格をmapで示すことができれば面白いなと思ったのがこの記事を書くことにしたきっかけです。

スクレイピング

賃貸検索サイトから都心部(中央区、千代田区、文京区、港区、新宿区)、南部(品川区、目黒区、大田区、世田谷区、渋谷区)の物件情報をスクレイピング。データベースに格納。集めた情報は以下。

  • 賃貸価格
  • 住所
  • 緯度
  • 経度
  • 間取り
  • バス・トイレが別か
  • オートロックの有無

以下のようなデータモデルを定義しデータベースに格納。個人的な勉強のためdjangoでデータモデルを定義しています。今回の分析で使っていないアトリビュートもありますが無視してください。

class Rentproperty(models.Model):
    property_id = models.AutoField(primary_key=True)
    # 登録日
    date = models.DateTimeField(blank=True, null=True, auto_now=True)
    # 賃料
    rent = models.FloatField(blank=True, null=True)
    # 管理費
    kanrihi = models.FloatField(blank=True, null=True)
    # 敷金
    sikikin = models.FloatField(blank=True, null=True)
    # 礼金
    reikin = models.FloatField(blank=True, null=True)
    # 物件名
    subtitle = models.CharField(max_length=50, blank=True, null=True)
    # 住所
    location = models.CharField(max_length=50, blank=True, null=True)
    # 緯度
    latitude = models.FloatField(blank=True, null=True)
    # 経度
    longititude = models.FloatField(blank=True, null=True)
    # 最寄り駅までの徒歩での所要時間
    close_station = models.IntegerField(blank=True, null=True)
    # 間取り
    floor_plan = models.CharField(max_length=50, blank=True, null=True)
    # 面積
    area = models.FloatField(blank=True, null=True)
    # 築年数
    age = models.FloatField(blank=True, null=True)
    # 階数
    floor = models.IntegerField(blank=True, null=True)
    # 向き
    orientation = models.CharField(max_length=5, blank=True, null=True)
    # バストイレ別かどうか
    bath_toilet = models.BooleanField(blank=True, null=True)
    # オートロック
    auto_lock = models.BooleanField(blank=True, null=True)
    # URL
    url = models.CharField(max_length=1000, blank=True, null=True)

パーサーは以下。パーサーも今回関係ない情報集めています。

URL = 'http://www.geocoding.jp/api/'

SUUMO_URL_DICT = {
    '中央区': 'https://suumo.jp/chintai/tokyo/sc_chuo/',
    '千代田区': 'https://suumo.jp/chintai/tokyo/sc_chiyoda/',
    '文京区': 'https://suumo.jp/chintai/tokyo/sc_bunkyo/',
    '港区': 'https://suumo.jp/chintai/tokyo/sc_minato/',
    '新宿区': 'https://suumo.jp/chintai/tokyo/sc_shinjuku/',
    '品川区': 'https://suumo.jp/chintai/tokyo/sc_shinagawa/',
    '目黒区': 'https://suumo.jp/chintai/tokyo/sc_meguro/',
    '大田区': 'https://suumo.jp/chintai/tokyo/sc_ota/',
    '世田谷区': 'https://suumo.jp/chintai/tokyo/sc_setagaya/',
    '渋谷区': 'https://suumo.jp/chintai/tokyo/sc_shibuya/'
}


class SuumoParser:
    def __init__(self, url):
        self.url = url
        self.pages_num = self.get_pages()
        self.urls = self.get_urls()

    def get_pages(self):
        result = requests.get(self.url)
        content = result.content
        soup = BeautifulSoup(content)
        body = soup.find("body")
        pages = body.find_all("div", {'class':'pagination pagination_set-nav'})
        pages_text = str(pages)
        pages_split = pages_text.split('</a></li>\n</ol>')
        pages_num = pages_split[0][-3:].replace('>','')
        pages_num = int(pages_num)
        return pages_num

    def get_summary(self, url):
        results = requests.get(url)
        content = results.content

        soup = BeautifulSoup(content)
        summary = soup.find("div", {"id":"js-bukkenList"})
        return summary

    def get_urls(self):
        urls = []
        urls.append(self.url)

        for i in range(self.pages_num-1):
            pg = str(i+2)
            url = self.url + '?page=' + pg
            urls.append(url)
        return urls

    def insert_db(self, url):
        summary = self.get_summary(url)
        cassetteitems = summary.find_all("div",{'class':'cassetteitem'})

        for cassetteitem in cassetteitems:
            try:
                title = self._get_title(cassetteitem)
                address = self._get_address(cassetteitem)
                latitude, longititude = self._get_coordinate(address)
                age = self._get_age(cassetteitem)
                close_station = self._get_close_station(cassetteitem)
                tables = cassetteitem.find_all('table')
                for table in tables:
                    # date = datetime.now
                    rent = self._get_rent(table)
                    kanrihi = self._get_administration(table)
                    sikikin = self._get_sikikin(table)
                    reikin = self._get_reikin(table)
                    area = self._get_area(table)
                    floor = self._get_floor(table)
                    floor_plan = self._get_floor_plan(table)
                    detail_url = self._get_detail_url(table)
                    url_all = urllib.parse.urljoin(url, detail_url)
                    bath_toilet, auto_lock = self._get_details(url_all)
                    # print(url_all)

                    rp = Rentproperty(
                        # date=date,
                        rent=rent,
                        kanrihi=kanrihi,
                        sikikin=sikikin,
                        reikin=reikin,
                        subtitle=title,
                        location=address,
                        latitude=latitude,
                        longititude=longititude,
                        close_station=close_station,
                        floor_plan=floor_plan,
                        area=area,
                        age=age,
                        floor=floor,
                        bath_toilet=bath_toilet,
                        auto_lock=auto_lock,
                        url=url_all
                    )
                    if len(Rentproperty.objects.filter(url=url_all).all()) == 0:
                        rp.save()
                    else:
                        rp = Rentproperty.objects.filter(url=url_all).first()
                        rp.save()
            except Exception as e:
                print(e)

    def _get_title(self, cassetteitem):
        #マンション名取得
        try:
            subtitle = cassetteitem.find_all("div",{
                'class':'cassetteitem_content-title'})
            subtitle = str(subtitle)
            subtitle = subtitle.replace('[<div class="cassetteitem_content-title">', '')
            subtitle = subtitle.replace('</div>]', '')
        except Exception as err:
            print('_get_title: ', err)
        return subtitle

    def _get_address(self, cassetteitem):
        #住所取得
        try:
            subaddress = cassetteitem.find_all("li",{'class':'cassetteitem_detail-col1'})
            subaddress = str(subaddress)
            subaddress = subaddress.replace('[<li class="cassetteitem_detail-col1">', '')
            subaddress = subaddress.replace('</li>]', '')
            return subaddress
        except Exception as err:
            print('_get_address: ', err)

    def _get_age(self, cassetteitem):
        try:
            col3 = cassetteitem.find("li",{'class':'cassetteitem_detail-col3'})
            col = col3.find('div')
            age = col.find(text=True)
            age = age[1:-1]
        except Exception as err:
            print('_get_age: ', err)
        try:
            age = float(age)
        except Exception as e:
            age = None
        return age

    def _get_rent(self, table):
        try:
            rent = table.find("span", {"class":"cassetteitem_price cassetteitem_price--rent"})
            rent = rent.text
            if '万円' in rent:
                rent = str(rent)[:-2]
                rent = float(rent)
            else:
                rent = None
            return rent
        except Exception as err:
            print('_get_rent :', err)

    def _get_administration(self, table):
        try:
            rent = table.find("span", {"class":"cassetteitem_price cassetteitem_price--administration"})
            rent = rent.text
            if '円' in rent:
                rent = str(rent)[:-1]
                rent = float(rent)
                rent /= 10000
            else:
                rent = 0
            return rent
        except Exception as err:
            print('_get_administration: ', err)

    def _get_sikikin(self, table):
        try:
            rent = table.find("span", {"class":"cassetteitem_price cassetteitem_price--deposit"})
            rent = rent.text
            if '万円' in rent:
                rent = str(rent)[:-2]
            else:
                rent = 0
            return rent
        except Exception as err:
            print('_get_sikikin: ', err)

    def _get_reikin(self, table):
        try:
            rent = table.find("span", {"class":"cassetteitem_price cassetteitem_price--gratuity"})
            rent = rent.text
            if '万円' in rent:
                rent = str(rent)[:-2]
                rent = float(rent)
            else:
                rent = 0
            return rent
        except Exception as err:
            print('_get_reikin: ', err)

    def _get_area(self, table):
        try:
            area = table.find("span", {"class":"cassetteitem_menseki"})
            area = area.text
            if 'm2' in area:
                area = area[:-2]
                area = float(area)
            else:
                area = None
            return area
        except Exception as err:
            print('_get_area: ', err)

    def _get_close_station(self, cassetteitem):
        try:
            station_list = cassetteitem.find_all("div", {"class":"cassetteitem_detail-text"})
            station_list_min = []
            for station in station_list:
                station = station.text
                if '歩' in station and '分' in station:
                    start = station.find(' 歩')
                    end = -1
                    station_min = station[start+2:end]
                    station_min = int(station_min)
                    station_list_min.append(station_min)

            if len(station_list_min):
                return min(station_list_min)
            else:
                return None
        except Exception as err:
            print('_get_close_sation: ', err)

    def _get_floor_plan(self, table):
        try:
            floor_plan = table.find("span", {"class":"cassetteitem_madori"})
            floor_plan = floor_plan.text
            return floor_plan
        except Exception as err:
            print('_get_floor_plan: ', err)

    def _get_floor(self, table):
        try:
            contents = table.find_all("td")
            floor = contents[2].text
            floor = floor[:-1]
            floor = int(floor)
            return floor
        except Exception as err:
            print('_get_floor :', err)

    def _get_detail_url(self, table):
        try:
            url = table.find("td", {"class":"ui-text--midium ui-text--bold"}).find("a").get("href")
            return url
        except Exception as err:
            print('_get_detail_url: ', err)

    def _get_details(self, detail_url):
        """detail url1を受け取り、「部屋の特徴・設備」の中身を返す"""
        try:
            results = requests.get(detail_url)
            content = results.content

            soup = BeautifulSoup(content, features="html.parser")
            summary = soup.find("div", {"class":"section l-space_small"}).find("li").text

            if summary == "":
                return None, None
            else:
                bath_toilet = 'バストイレ別' in summary
                auto_lock = 'オートロック' in summary
                return bath_toilet, auto_lock
        except Exception as err:
            print('_get_details :', err)

    def _get_coordinate(self, address):
        try:
            if len(AddressCoordinate.objects.filter(address=address)):
                instance = AddressCoordinate.objects.get(address=address)
                time.sleep(10)
                return instance.latitude, instance.longititude
            else:
                latitude, longititude = _coordinate(address)
                ac = AddressCoordinate(address=address,
                                       latitude=latitude,
                                       longititude=longititude)
                ac.save()
                return latitude, longititude
        except Exception as err:
            print('_get_coordinate: ', err)


def _coordinate(address):
    payload = {'q': address}
    html = requests.get(URL, params=payload)
    soup = BeautifulSoup(html.content, "html.parser")
    if soup.find('error'):
        raise ValueError(f"Invalid address submitted. {address}")
    latitude = soup.find('lat').string
    longitude = soup.find('lng').string
    time.sleep(10)
    return latitude, longitude


if __name__ == '__main__':
    for city_name, city_url in SUUMO_URL_DICT.items():
        sp = SuumoParser(city_url)
        urls = sp.get_urls()
        for i, url in enumerate(urls):
            try:
                print(city_name, i, url)
                sp.insert_db(url)
            except Exception as err:
                print(err)

DBからデータの取り出し

各住所での平均の家賃を取り出したいので、基本のクエリ文はSELECT AVG(rent), location FROM rentproperty GROUP BY locationとなります。加えて、ある程度条件を絞って各住所での平均を取らないと賃料の高さが純粋な場所由来なのか面積やオートロック有無などの条件の場所での偏りによるものなのか分からなくなると思い、条件をある程度絞りました

  • 1K
  • オートロック有り
  • バストイレ別
  • 25 m2 以上30 m2以下

これを踏まえると、クエリ文は、SELECT AVG(rent), location WHERE floor_plan='1K' AND auto_lock=True AND bath_toilet=True AND area > 25 and area < 30 FROM rentproperty GROUP BY locationとなります

mapでの平均賃料の可視化

地図表示にはgeopandasというライブラリを使いました。本当は住所などが記載されているmapに重ね合わせたものを作成したかったのですが方法がわかりませんでした。
こちらみるとやはり港区、千代田区が高く、都心から離れるにつれ安くなっていることがわかります。局所的に平均賃貸価格が高くなっている場所や安くなっている場所があるところが面白いです。N数がただ足らない、新しい家や古い家が偏って立っているということも考えられますが、もしかすると割高な地区、お得な地区というのがあるのかもしれません。