Scalaとjsoupでパチンコ屋の出玉情報をクローリングする

[ただの集団 Advent Calendar 2018](https://adventar.org/calendars/3447) の1日目の記事。

# はじめに
パチンコ屋の出玉情報を公開するサイトは増えてきたが、情報は各ページに散っていて、WebAPIも提供されていないので、情報を集約して活用するにはクローリングを行う必要がある。

仕事ではScalaを勉強中なので、`Scala` + `jsoup`で[PAPIMO-NET](http://papimo.jp/)をクローリングするサンプルを実装する。

# なぜjsoupか？
- [Akka HTTP Client](https://doc.akka.io/docs/akka-http/current/client-side/index.html)
  - 本来はScalaらしくAkka HTTPを使用する予定だったが、単独ではスクレイピングに向かないので別途パーサーを使う手間を考慮してやめた。
- [scala-scraper](https://github.com/ruippeixotog/scala-scraper)
  - Scala製で使いやすそうだが、しばらくメンテが行われていないように見受けられる。一般にScalaはJavaよりもバージョンアップにかかるコストが高く、メンテが定期的に行われていないライブラリは後のリスクとなる恐れがあるので見送った。
- [jsoup](https://jsoup.org/)
  - javaのHTML Parserライブラリ。cssセレクタが利用でき、 [Try jsoup](https://try.jsoup.org/)を使用したブラウザ上での手軽な検証も魅力。

# クローリング時の注意点
## robots.txt
クローラーに対するサイトのポリシーはドメインの直後の`robots.txt`に書かれているのでクローラーは従う必要がある。
今回の対象のサイトでは`Crawl-delay: 10`とあるため10秒間隔でアクセスする。
また、`Disallow`で許可されていないページは避けてクローリングを行う。

# 実装する
完成したコードは下記にアップした。
https://github.com/nhiguchi/ps-crawler-sample

## jsoupでHTTPリクエストをする
このサイトでは画面遷移がGETだけではなくPOSTの場合もあるので、リクエスト用メソッドを両方用意した。

```scala
def requestByGet(url: String) = {
  getConnection(url).get
}

def requestByPost(url: String) = {
  getConnection(url).post
}

def getConnection(url: String) = {
  Thread.sleep(10000)
  Jsoup.connect(url).userAgent("ps crawler").timeout(30000)
}
```

## ホールトップ → 機種一覧
対象ページ例 http://papimo.jp//h/00031701/hit/top

jsoupの基本は`Document`に対して`select`メソッドでCSSセレクタを使って要素を特定していく。
属性を取得する場合は`attr`メソッドを使う。

```scala
val SiteDomainUrl = "http://papimo.jp"
val HallTopUrl = SiteDomainUrl + "/h/00031701/hit/top"

println("Start.")
val today = LocalDate.now()

// ホールトップ
val hallTopDoc = requestByGet(HallTopUrl)

// ホールトップ → 機種一覧
val modelListUrl = SiteDomainUrl + hallTopDoc
  .select(".menu-top li")
  .get(2)
  .select("a")
  .attr("href")
println(s"modelListUrl=[$modelListUrl]")
val modelListDoc = requestByPost(modelListUrl)
```

## 機種一覧 → 台一覧
対象ページ例 http://papimo.jp/h/00031701/hit/index_sort/213120003/1-20-179596 

```scala
// 機種一覧 → 台一覧
val machineListUrls = modelListDoc
  .select("ul.item")
  .select("a")
  .asScala
  .map(SiteDomainUrl + _.attr("href"))
println(s"machineListUrlsSize=[${machineListUrls.size}]")
println(s"machineListUrls=[$machineListUrls]")
val machineListDocs = machineListUrls.map(requestByGet)
```

## 台一覧 → 台詳細
対象ページ例 http://papimo.jp/h/00031701/hit/view/301

```scala
// 台一覧 → 台詳細
val machineDetailUrls = machineListDocs.flatMap {
  _
    .select(".unit_no")
    .asScala
    .map(SiteDomainUrl + _.select("a").attr("href"))
}
println(s"machineDetailUrlsSize=[${machineDetailUrls.size}]")
println(s"machineDetailUrls=[$machineDetailUrls]")
val machineDetailDocs = machineDetailUrls.map(requestByGet)
```
## 台履歴用case class
```scala
case class MachineHistory(
                           machineNo: Int,
                           machineName: String,
                           bbCount: Int,
                           rbCount: Int,
                           totalStartCount: Int,
                           finalStartCount: Int,
                           maxOutputMedal: Int,
                           differenceMedal: Int,
                           graphUrl: String,
                           date: LocalDate,
                         )
```

## 台詳細を取得
対象ページ例 http://papimo.jp/h/00031701/hit/view/301

```scala
// 台詳細を取得
val machineHistories = machineDetailDocs.map { doc =>
  val graphRelativeUrl = doc
    .select(".graph-some td")
    .last()
    .select("img")
    .attr("src")
  val graphUrl = graphRelativeUrl match {
    // 絶対パスで返ってくる場合と相対パスで返ってくる場合がある
    case url if url.startsWith("http") => url
    case url => SiteDomainUrl + url
  }
  MachineHistory(
    machineNo = doc.select(".unit_no").get(0).text().diff("番台").toInt,
    machineName = doc.select(".name").text(),
    bbCount = getFromMachineDetail(doc, "BB回数"),
    rbCount = getFromMachineDetail(doc, "RB回数"),
    totalStartCount = getFromMachineDetail(doc, "総スタート"),
    finalStartCount = getFromMachineDetail(doc, "最終スタート"),
    maxOutputMedal = getFromMachineDetail(doc, "最大出メダル"),
    differenceMedal = getDifferenceMedal(graphUrl),
    graphUrl = graphUrl,
    date = today,
  )
}
println(machineHistories)

def getFromMachineDetail(machineDetailDoc: Document, name: String) = {
  val value = machineDetailDoc.select(":containsOwn(" + name + ")").select("p").text()
  value match {
    case "-" => 0
    case _ => value.replace(",", "").toInt // 数字の桁区切りのカンマは削除
  }
}
```

## 出玉グラフの画像解析
最も重要な情報である差枚数がHTML上にないためグラフ画像を解析する。
`javax.imageio.ImageIO`を使用して最も最新の差枚数を取得している。

```scala
def getDifferenceMedal(graphUrl: String) = {
  val graphImage = ImageIO.read(new URL(graphUrl))
  val rightmostY = (0 until graphImage.getWidth).flatMap { x =>
    (0 until graphImage.getHeight).map { y =>
      val color = graphImage.getRGB(x, y)
      val r = color >> 16 & 0xff
      val g = color >> 8 & 0xff
      val b = color >> 0 & 0xff
      (x, y, r, g, b)
    }
  }.filter(t => t._3 == 236 && t._4 == 34 && t._5 == 52)
  .maxBy(_._1)._2
  (114375 - (375 * rightmostY)) / 9
}
```

## 解析して出力
実運用するのであればDB等に保存した方が良いが、今回は一旦機種ごとの日別差枚数を集計して出力してみた。

```scala
// 解析して出力
val differenceMedalsByModel = machineHistories
  .groupBy(_.machineName)
  .mapValues(_.foldLeft(0)(_ + _.differenceMedal))
println(differenceMedalsByModel)

println("End.")
```

### 出力結果
期待通りの結果が得られた。
（値はダミー）

```
Map(スーパーミラクルジャグラー -> 777, SLOT魔法少女まどか☆マギカ -> -777, ゴーゴージャグラー -> 777, アイムジャグラーEX－AE グリーンパネル -> -777, アイムジャグラーEX－AE -> 777, 押忍!番長3 -> -777, ニューパルサーSPⅡ -> 777, ハッピージャグラーVⅡ -> -777)
```

# 実運用に向けて
- 対象サイト側のHTMLに修正が入るとスクレイピングができなくなってしまうので何かしら検知する仕組みが必要。
- パチンコ屋が閉店している時間は一般に23:00~10:00なので、閉店時間中に時間指定で実行できる仕組みが必要。
- 1サイトであればベタで書いても良いがサイトが増えてくると大変なので、ある程度処理を共通化してコードをメンテせずにJSON等で書かれた定義をメンテすれば良いようにしたい。

# まとめ
- jsoupを用いることで簡単にクローラーが作成できた。
- クローラーを使用することでパチンコ屋の情報収集が自動化できた。対象店や対象サイトを増やしていけば店別・日別の比較が簡単にできるようになるので、情報を活用すれば台選定時の確度向上に役立つと思う。

完成したコードは下記にアップした。
https://github.com/nhiguchi/ps-crawler-sample