🤖

【Java】SUUMOのサイトをスクレイピングして欲しい情報をまとめるアプリを作ってみた

2024/04/03に公開

はじめに

学習と趣味の一環でSuumoのスクレイピングを行ってみた。
Suumoでは物件一覧で住所が表示されているが、物件ごとの写真はクリックしないと表示できないし、地図情報は一覧化されていない。
そのため、物件情報を取得し、それらをまとめて表示させるアプリを作成してみた。
※あくまでも趣味の範囲でブラッシュアップして商用に展開する予定もありません。

この記事でわかること

  1. Javaを使ってスクレイピングをする
  2. スクレイピングしたものをthymeleafを使ってhtmlで表示させる
  3. leafletを使って地図を表示する

完成イメージ


こんな感じで、スクレイピングとAPIは実行されています。
スクレイピングして得た住所からジオコーダAPIで地図座標を取得し、それをThymeleafを使って地図表示させています。

完成アプリはこんな挙動をします。

Controllerを作る

まずはhtmlでリクエストを飛ばした際に実行されるControllerを作成します。

@Controller
public class ScrapingController {
	@GetMapping("/scraping")
    public String openText(@RequestParam( name= "url",defaultValue="https://www.google.com/") String linkUrl,Model model) throws IOException {...

これでURLが/scrapingでGetMethodの場合に実行されるControllerを作成。
nameはhtmlのinputタグのname属性(="url"のもの)とリンクさせるために記載。
初期表示の際inputタグの値がからとなるため、デフォルトの値をGoogleのリンクとしました。(めっちゃいい加減に設定)

htmlで入力フォームを作成

<p th:text="'S〇〇moの絞り込み後(建物ごとに表示)のリンクを入力してください'"></p>
	<a th:href="@{https://suumo.jp/}" target="_blank" rel="noopener noreferrer">S〇〇mo</a>
	<form th:action="@{/scraping}" method="get">
		<label th:text="URL">URL</label>
		<input type="text" name ="url">
		<input type="submit" value="送信"/>
	</form>


これでスクレイピングをする対象のURLを入力するフォームが完成しました。

SUUMOサイトからスクレイピングをする

Javaでは、jsoupスクレイピングをするためのライブラリが用意されています。
使い方はこちら
これを使うためにpom.xmlへ下記の記載をします。

<dependency>
    <groupId>org.jsoup</groupId>
    <artifactId>jsoup</artifactId>
    <version>1.17.2</version>
</dependency>

これでJsoupが使えるようになったため、Controller使っていきます。

//linkUrlはhtmlから
Document doc = Jsoup.connect(linkUrl).get();

linkUrlは、GETメソッドで/scrapingにリクエストを送った際に入力フォーム内へ入力したリンクが入ります。
これで対象のURLに対してスクレイピングをする準備が整いました。

対象の画面のclass(.casseteitem)からスクレイピングする


各物件はクラス名cassetitemでまとめられていました。
なのでクラス名がcassetteitemのブロックを全て取得します。

Elements cassetteItem = doc.select(".cassetteitem");

そこから
欲しい部分を取得してList化していきます。

List<String> propertyNameList = new LinkedList<String>();
List<String> addressList = new LinkedList<String>();
List<String> ageList = new LinkedList<String>();
List<String> informationList = new LinkedList<String>();
List<String[]> coordinatesList = new LinkedList<String[]>();
List<String> travelTimeList = new LinkedList<String>();
List<String> photoImageList = new LinkedList<String>();
List<List<String>> imgUrlInclusiveList = new LinkedList<List<String>>();

for (Element element : cassetteItem) { 

    String propertyName = element.select(".cassetteitem_content-title").text();
    String address = element.select(".cassetteitem_detail-col1").text();
    String age = element.select(".cassetteitem_detail-col3").text();
    String information = element.select(".js-cassette_link").text()
            .replace("追加", "")
            .replace("詳細を見る", "")
            .replace("お問い合わせ (無料)", "")
            .replace("動画", "")
            .replace("パノラマ", "");
    Element img = element.selectFirst(".js-cassette_link").selectFirst(".casssetteitem_other-thumbnail");

    String imgUrl = img.attr("data-imgs");
    String[] imgUrlArray = imgUrl.split(",");
    List<String> imgUrlList = new LinkedList<String>();
    for (String urlStr : imgUrlArray) {
        imgUrlList.add(urlStr);
    }
    imgUrlInclusiveList.add(imgUrlList);
    
    
    Element photo = element.selectFirst(".cassetteitem_object-item").selectFirst("img");
    String photoImage = photo.attr("rel");

    if (!element.select(".cassetteitem_transfer-body").isEmpty()) {
        String travelTime = element.select(".cassetteitem_transfer-body").text();
        travelTimeList.add(travelTime);
    }
    
    
    propertyNameList.add(propertyName);
    addressList.add(address);
    ageList.add(age);
    informationList.add(information);
    photoImageList.add(photoImage);

※ここでfor文の中のaddressは各物件の住所です。

Yahoo!ジオコーダAPIを使って住所から座標を取得する

address座標をそれぞれ取得していきます。
ジオコーダAPIのパラメータにClientIDと住所をセットして実行します。

OkHttpClient client = new OkHttpClient();
String apiKey = System.getenv("YOPL_OPEN_API_KEY");
String place = address;
String url = "https://map.yahooapis.jp/geocode/V1/geoCoder?appid="+apiKey+"&query="+place+"&output=json";
Request request = new Request.Builder()
   .url(url)
   .build();

Response response = client.newCall(request).execute();

そして返り値のCoordinatesには座標が入っていますので、それを配列にしてListに詰めこめます。

String jsonStr =response.body().string();

ObjectMapper mapper = new ObjectMapper();
JsonNode rootNode = mapper.readTree(jsonStr);
JsonNode firstFeatureNode = rootNode.get("Feature").get(0);


String coordinates = firstFeatureNode.get("Geometry").get("Coordinates").asText();
String[] coordinatesArray = coordinates.split(",");
coordinatesList.add(coordinatesArray);

そしてthymeleafを使って表示できるように、モデルクラスにセットして、最後にhtml("scraping")をreturnします。

model.addAttribute("ageList", ageList);
model.addAttribute("propertyNameList", propertyNameList);
model.addAttribute("addressList", addressList);
model.addAttribute("coordinatesList", coordinatesList);
model.addAttribute("informationList", informationList);
model.addAttribute("travelTimeList", travelTimeList);
model.addAttribute("photoImageList", photoImageList);
model.addAttribute("imgUrlInclusiveList", imgUrlInclusiveList);

return "scraping";

画面を作る

全体のコード

html

<div th:each="property, iterStat: ${propertyNameList}">
    <div class="container">
        
        <div class="left">
                <div th:each="imgUrlList, iterStat: ${imgUrlInclusiveList[iterStat.index]}">
                    <div th:each="imgUrl, iterStat: ${imgUrlList}">
                        <img th:src="${imgUrl}" style="width: 15rem;">
                    </div>
                </div>
        </div>
        <div class="center">
            <p th:text="'物件名:'+${property}">物件名</p>
            <p th:text="'住所:'+${addressList[iterStat.index]}">住所</p>
            <p th:utext="'情報:'+${#strings.replace(informationList[iterStat.index],'m2','m2<br>&thinsp;&thinsp;&thinsp;&nbsp;&nbsp;&nbsp;&ensp;&ensp;')}">情報</p>
            <p th:text="'築年数:'+${ageList[iterStat.index]}">住所</p>
            <p th:if="!${#lists.isEmpty(travelTimeList)}" th:text="'移動時間:'+${travelTimeList[iterStat.index]}">移動時間</p>
        </div>
        <div class="right">
            <div class ="map" th:id="map+${iterStat.index}" th:attr="data-store-name=${property},data-latitude=${coordinatesList[iterStat.index][1]},data-longitude=${coordinatesList[iterStat.index][0]}" style="height: 30rem;"></div>
        </div>
        <br>
    </div>
    </div>

css

.container {
	display: flex;
	margin-bottom:5rem;
	border: 1px solid #000000;
}

.center,.right{
width: 40%;
height: 30rem;
padding-left:1rem;
}
.left {
	width: 20%;
	height: 30rem;
	margin:auto;
	overflow-x: scroll;
}


.left img {
	cursor: pointer;
}

分解する

  • 繰り返し処理
<div th:each="property, iterStat: ${propertyNameList}">

これでSUUMOの画像データを繰り返し処理で表示さる準備をしていきます。

  • 物件ごとにまとめる
<div class="container">

  • 画面左部分を作る
<div class="left">
    <div th:each="imgUrlList, iterStat: ${imgUrlInclusiveList[iterStat.index]}">
        <div th:each="imgUrl, iterStat: ${imgUrlList}">
            <img th:src="${imgUrl}" style="width: 15rem;">
        </div>
    </div>
</div>

  • 画面中央部分を作る
<div class="center">
    <p th:text="'物件名:'+${property}">物件名</p>
    <p th:text="'住所:'+${addressList[iterStat.index]}">住所</p>
    <p th:utext="'情報:'+${#strings.replace(informationList[iterStat.index],'m2','m2<br>&thinsp;&thinsp;&thinsp;&nbsp;&nbsp;&nbsp;&ensp;&ensp;')}">情報</p>
    <p th:text="'築年数:'+${ageList[iterStat.index]}">住所</p>
    <p th:if="!${#lists.isEmpty(travelTimeList)}" th:text="'移動時間:'+${travelTimeList[iterStat.index]}">移動時間</p>
</div>

  • 画面右部分を作る
<div class="right">
    <div class ="map" th:id="map+${iterStat.index}" th:attr="data-store-name=${property},data-latitude=${coordinatesList[iterStat.index][1]},data-longitude=${coordinatesList[iterStat.index][0]}" style="height: 30rem;"></div>
    </div>

leafletで地図を作成する

これは過去に同じようなものを書いているのでそちらをご参照ください。
https://zenn.dev/gonchan/articles/8f226c3a81ed3a

window.addEventListener("load", function() {
	
    // 地図を表示する要素の数だけ繰り返し
    var elements = document.querySelectorAll(".map");
    elements.forEach(function(e, index) {
        var latitude = e.getAttribute('data-latitude');
        var longitude = e.getAttribute('data-longitude');
        var shopName = e.getAttribute('data-store-name');

		// 文字列から数値に変換
        var lat = parseFloat(latitude); 
        var lng = parseFloat(longitude); 
        

        var map = L.map(e.id).setView([lat, lng], 10);
        
        L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
            attribution: "© OpenStreetMap contributors"
        }).addTo(map);
		
		

		
		
        // マーカーを追加
        var marker = L.marker([lat, lng]).addTo(map);
        marker.bindPopup(shopName).openPopup(); 
    });
    alert('表示しました');
});

これで完成しました。

全コード

  • ScrapingController
package com.example.demo.controller;

import java.io.IOException;
import java.util.LinkedList;
import java.util.List;

import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.squareup.okhttp.OkHttpClient;
import com.squareup.okhttp.Request;
import com.squareup.okhttp.Response;

@Controller
public class ScrapingController {
	
	@GetMapping("/scraping")
	public String openText(@RequestParam( name= "url",defaultValue="https://www.google.com/") String linkUrl,Model model) throws IOException {
		

        Document doc = Jsoup.connect(linkUrl).get();
        
        Elements cassetteItem = doc.select(".cassetteitem");
        int tatalSize = cassetteItem.size();
        model.addAttribute("totalSize",tatalSize);
        List<String> propertyNameList = new LinkedList<String>();
        List<String> addressList = new LinkedList<String>();
        List<String> ageList = new LinkedList<String>();
        List<String> informationList = new LinkedList<String>();
        List<String[]> coordinatesList = new LinkedList<String[]>();
        List<String> travelTimeList = new LinkedList<String>();
        List<String> photoImageList = new LinkedList<String>();
        List<List<String>> imgUrlInclusiveList = new LinkedList<List<String>>();
        
        for (Element element : cassetteItem) { 
    
            String propertyName = element.select(".cassetteitem_content-title").text();
            String address = element.select(".cassetteitem_detail-col1").text();
            String age = element.select(".cassetteitem_detail-col3").text();
            String information = element.select(".js-cassette_link").text()
                    .replace("追加", "")
                    .replace("詳細を見る", "")
                    .replace("お問い合わせ (無料)", "")
                    .replace("動画", "")
                    .replace("パノラマ", "");
            Element img = element.selectFirst(".js-cassette_link").selectFirst(".casssetteitem_other-thumbnail");
    
            String imgUrl = img.attr("data-imgs");
            String[] imgUrlArray = imgUrl.split(",");
            List<String> imgUrlList = new LinkedList<String>();
            for (String urlStr : imgUrlArray) {
                imgUrlList.add(urlStr);
            }
            imgUrlInclusiveList.add(imgUrlList);
            
            
            Element photo = element.selectFirst(".cassetteitem_object-item").selectFirst("img");
            String photoImage = photo.attr("rel");
    
            if (!element.select(".cassetteitem_transfer-body").isEmpty()) {
                String travelTime = element.select(".cassetteitem_transfer-body").text();
                travelTimeList.add(travelTime);
            }
            
            
            propertyNameList.add(propertyName);
            addressList.add(address);
            ageList.add(age);
            informationList.add(information);
            photoImageList.add(photoImage);
            
            OkHttpClient client = new OkHttpClient();
            String apiKey = System.getenv("YOPL_OPEN_API_KEY");
            String place = address;
            String url = "https://map.yahooapis.jp/geocode/V1/geoCoder?appid="+apiKey+"&query="+place+"&output=json";
            Request request = new Request.Builder()
               .url(url)
               .build();
    
            Response response = client.newCall(request).execute();
            String jsonStr =response.body().string();
    
            ObjectMapper mapper = new ObjectMapper();
            JsonNode rootNode = mapper.readTree(jsonStr);
            JsonNode firstFeatureNode = rootNode.get("Feature").get(0);
    
    
            String coordinates = firstFeatureNode.get("Geometry").get("Coordinates").asText();
            String[] coordinatesArray = coordinates.split(",");
    
            coordinatesList.add(coordinatesArray);
        }
        
        model.addAttribute("ageList", ageList);
        model.addAttribute("propertyNameList", propertyNameList);
        model.addAttribute("addressList", addressList);
        model.addAttribute("coordinatesList", coordinatesList);
        model.addAttribute("informationList", informationList);
        model.addAttribute("travelTimeList", travelTimeList);
        model.addAttribute("photoImageList", photoImageList);
        model.addAttribute("imgUrlInclusiveList", imgUrlInclusiveList);
        
        return "scraping";
		
	}
}
  • scraping.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Scraping</title>
	<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css" />
	<script src="https://unpkg.com/leaflet@1.3.0/dist/leaflet.js"></script>
	<link rel="stylesheet" href="css/scraping.css" />
     <script src="js/leaflet.js"></script>
</head>
<body>
	<p th:text="'S〇〇moの絞り込み後(建物ごとに表示)のリンクを入力してください'"></p>
	<a th:href="@{https://suumo.jp/}" target="_blank" rel="noopener noreferrer">S〇〇mo</a>
	<form th:action="@{/scraping}" method="get">
		<label th:text="URL">URL</label>
		<input type="text" name ="url">
		<input type="submit" value="送信"/>
	</form>
	<div>
		<h3 th:text="'全部で'+${totalSize}+'物件です'"></h3>
	</div>
	<div th:each="property, iterStat: ${propertyNameList}">
		<div class="container">
			
			<div class="left">
				    <div th:each="imgUrlList, iterStat: ${imgUrlInclusiveList[iterStat.index]}">
				        <div th:each="imgUrl, iterStat: ${imgUrlList}">
				            <img th:src="${imgUrl}" style="width: 15rem;">
				        </div>
				    </div>
			</div>
			<div class="center">
				<p th:text="'物件名:'+${property}">物件名</p>
				<p th:text="'住所:'+${addressList[iterStat.index]}">住所</p>
				<p th:utext="'情報:'+${#strings.replace(informationList[iterStat.index],'m2','m2<br>&thinsp;&thinsp;&thinsp;&nbsp;&nbsp;&nbsp;&ensp;&ensp;')}">情報</p>
				<p th:text="'築年数:'+${ageList[iterStat.index]}">住所</p>
				<p th:if="!${#lists.isEmpty(travelTimeList)}" th:text="'移動時間:'+${travelTimeList[iterStat.index]}">移動時間</p>
			</div>
			<div class="right">
		    	<div class ="map" th:id="map+${iterStat.index}" th:attr="data-store-name=${property},data-latitude=${coordinatesList[iterStat.index][1]},data-longitude=${coordinatesList[iterStat.index][0]}" style="height: 30rem;"></div>
		    </div>
			<br>
		</div>
	</div>

</body>
</html>
  • scraping.css
.container {
	display: flex;
	margin-bottom:5rem;
	border: 1px solid #000000;
}

.center,.right{
width: 40%;
height: 30rem;
padding-left:1rem;
}
.left {
	width: 20%;
	height: 30rem;
	margin:auto;
	overflow-x: scroll;
}


.left img {
	cursor: pointer;
}


  • leaflet.js
window.addEventListener("load", function() {
	
    // 地図を表示する要素の数だけ繰り返し
    var elements = document.querySelectorAll(".map");
    elements.forEach(function(e, index) {
        var latitude = e.getAttribute('data-latitude');
        var longitude = e.getAttribute('data-longitude');
        var shopName = e.getAttribute('data-store-name');

		// 文字列から数値に変換
        var lat = parseFloat(latitude); 
        var lng = parseFloat(longitude); 
        

        var map = L.map(e.id).setView([lat, lng], 10);
        
        L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
            attribution: "© OpenStreetMap contributors"
        }).addTo(map);
		
		

		
		
        // マーカーを追加
        var marker = L.marker([lat, lng]).addTo(map);
        marker.bindPopup(shopName).openPopup(); 
    });
    alert('表示しました');
});

Discussion