20
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Docker + Vue.js + Go言語で映画館の空席状況を取得する

Last updated at Posted at 2021-12-13

ディップ Advent Calendar 2021の13日目の記事です。

はじめに

ディップ株式会社でPHP(Laravel)を使って求人系サービスの開発や社内向けツールの開発を行なっている@taku-0728です。
今回はタイトル通りDocker + Vue.js + Go言語を使って映画の上映状況を取得してみます。
Vue.jsとGo言語を使った記事を書くのは初めてでお作法的な部分もまだ理解が浅いため、
有識者の方からみると違和感を感じる部分もあると思いますが、こう書いた方がいい等あればコメントにてご指摘いただければ幸いです。

前置きがながいので、実装方法について詳しく知りたい方は実装からお読みください。

こんなこと経験ありませんか?

:grin::話題のあの映画がついに公開だって!今度の週末見に行きたい!近くの映画館で空いてる時間ないかなー

:movie_camera::「「「全時間帯満席です」」」

:angry::1日予定がないから何時でもいいのに空いてない:angry: 多少遠くてもいいから話題の映画が見られる映画館が知りたい:angry:

今回解決したい問題

はい、今回知りたいことは「特定の映画の空席がある映画館を知りたい」という内容となります。
通常、映画館の空席状況を知りたい場合、最寄りの映画館のサイトに行って空席状況を調べる方が多いのではないでしょうか?
映画館のサイトによっては近隣店舗の上映状況のリンクがある場合もありますが、一部のサイトではその施設内の上映状況しか記載されておらず、
空席がなかった場合に近隣の店舗名で調べ直さなければいけない場合がありました。

そこで、今回は
都道府県と映画のタイトルを入力すると、同じ都道府県内の映画館全てに対して、タイトルとして入力された映画の空席状況を取得するサイトを作ります。
本当ならば様々なサイトの空席状況を取得したかったのですが、今回は時間の都合上TOHOシネマズに絞ってやっていきます。

やり方

まずちらっと調べてみましたが、やはりTOHOシネマズの空席情報を含む映画館の情報を取得するAPIなどは見つからなかったので、今回情報の取得はスクレイピングで行います。

注意:スクレイピングは相手のサーバに相当な負荷をかける場合があります。アクセスとアクセスの間隔は1秒以上あけるなど、相手のサーバに負荷をかけないように注意してください。また、利用規約などでそもそもスクレイピングが禁止されている場合があります。スクレイピングを実施する前に相手のページの利用規約などをよく読んでください。

TOHOシネマズ ご利用に際してを確認し、利用規約に反していないと判断したのでこの記事を書いています。
ただし、何か問題があった場合は速やかに記事を削除するのでお手数ですがコメントにてお知らせください。

実装

前提条件

以降の実装では下記の条件が満たされていることを前提としています。
もし下記がまだの場合は本記事では説明を割愛するのでお手数ですが他の記事を参考にしていただければと思います。

  • ローカルでDockerが動作すること
  • ローカルでVue CLI 3が使えること
  • ローカルでgoコマンドが使えること(goコマンドは使えなくても後述のファイルをコピーすれば一応進められます)

環境構築

今回はなるべくローカル環境に影響を与えたくなかったので、環境構築はDockerを用いて行います。
まず任意のディレクトリ(ここではprojectsとします)を用意します。以降の作業は全てこのprojects内で行います。

$ mkdir projects

client側(Vue.js)

projects内に入ったら、まずはVue.jsの環境構築を行います。
ここではVue.jsのプロジェクト作成時の詳細なオプションについては割愛します。

$ vue create client
~~ 中略 ~~

$ cd client

clientディレクトリ配下にVue.jsのプロジェクトができたら、Dockerコンテナ内で動作させるためにDockerfileを作成します。

$ vi Dockerfile
# nodeイメージをpullする
FROM node:14.18.1

# コンテナログイン時のディレクトリ指定
WORKDIR /work

# カレントディレクトリのファイル全てをコンテナ内にコピー
COPY . .

# 依存モジュールのインストール
RUN yarn install

これでclient側の準備は完了です。

server側(Go言語)

一旦projectディレクトリに戻り、Go言語のソースを格納するserverディレクトリを作成します。

$ cd ../
$ mkdir server
$ cd server

serverディレクトリの中で同様にDockerfileを作成します。

$ vi Dockerfile

DockerFileの中身はこんな感じです。

# ベースとなるDockerイメージ指定
FROM golang:latest

# コンテナログイン時のディレクトリ指定
WORKDIR /work

これでserver/clientそれぞれのDockerfileが作成できました。
これらのコンテナの管理をするためにproject配下にdocker-compose.ymlを追加します。

cd ../
vi docker-compose.yml

中身はこんな感じです。

docker-compose.yml
version: '3.8' # composeファイルのバーション指定
services:
  # server
  server:
    build: ./server # ビルドに使用するDockerfileがあるディレクトリ指定
    tty: true # コンテナの起動永続化
    volumes:
      - ./server/src:/work # マウントディレクトリ指定
    container_name: server
    ports:
      - 8000:8000
  # client
  client:
    build: ./client
    tty: true # コンテナの起動永続化
    volumes:
      - ./client:/work # マウントディレクトリ指定
    container_name: client
    ports:
      - 8080:8080
    command: yarn serve

ここまでできたら一旦コンテナの起動はできるはずです。
下記コマンドでコンテナを起動してみてください。

$ docker-compose up -d

server側はコンテナを作成しただけですが、client側はプロジェクトを作成したディレクトリをマウントしていて、
docker-compose.yml内でサーバも立てているので動くはずです。http://localhost:8080 にアクセスしてVue.jsのトップページが表示されるか確認してみてください。
(本記事は一度実装してから手順は再現しながら書いたつもりですが、もし手違いでエラー等になる場合はお手数ですがコメントにてお知らせください。)
これで環境構築は終了です。

サーバ側での情報取得

各モジュールのインストール

環境構築が終了したので、次に画面で表示するためのデータ取得処理をサーバ側に実装します。
まず処理に必要なモジュールをインストールします。

$ go mod init scraping
$ go get -u github.com/PuerkitoBio/goquery
$ go get -u github.com/djimenez/iconv-go
$ go get -u github.com/labstack/echo
$ go get -u github.com/labstack/echo/middleware
$ go get -u github.com/sclevine/agouti

上記コマンドを実行するとカレンとディレクトリにgo.modgo.sumが配置されるはずです。 中身はこんな感じです。ローカル環境でgoコマンドを実行しなくてもこの2つのファイルをコピペして配置すれば進められるはずです。

go.mod
module scraping

go 1.14

require (
	github.com/PuerkitoBio/goquery v1.8.0
	github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect
	github.com/djimenez/iconv-go v0.0.0-20160305225143-8960e66bd3da
	github.com/labstack/echo v3.3.10+incompatible
	github.com/labstack/gommon v0.3.1 // indirect
	github.com/mattn/go-colorable v0.1.12 // indirect
	github.com/sclevine/agouti v3.0.0+incompatible
	golang.org/x/crypto v0.0.0-20211202192323-5770296d904e // indirect
	golang.org/x/net v0.0.0-20211208012354-db4efeb81f4b // indirect
	golang.org/x/sys v0.0.0-20211205182925-97ca703d548d // indirect
	golang.org/x/text v0.3.7 // indirect
)
go.sum
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgrijalva/jwt-go v1.0.2 h1:KPldsxuKGsS2FPWsNeg9ZO18aCrGKujPoWXn2yo+KQM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/djimenez/iconv-go v0.0.0-20160305225143-8960e66bd3da h1:0qwwqQCLOOXPl58ljnq3sTJR7yRuMolM02vjxDh4ZVE=
github.com/djimenez/iconv-go v0.0.0-20160305225143-8960e66bd3da/go.mod h1:ns+zIWBBchgfRdxNgIJWn2x6U95LQchxeqiN5Cgdgts=
github.com/labstack/echo v1.4.4 h1:1bEiBNeGSUKxcPDGfZ/7IgdhJJZx8wV/pICJh4W2NJI=
github.com/labstack/echo v3.3.10+incompatible h1:pGRcYk231ExFAyoAjAfD85kQzRJCRI8bbnE7CX5OEgg=
github.com/labstack/echo v3.3.10+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s=
github.com/labstack/gommon v0.3.1 h1:OomWaJXm7xR6L1HmEtGyQf26TEn7V6X88mktX9kee9o=
github.com/labstack/gommon v0.3.1/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
github.com/mattn/go-colorable v0.1.11 h1:nQ+aFkoE2TMGc0b68U2OKSexC+eq46+XwZzWXHRmPYs=
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sclevine/agouti v3.0.0+incompatible h1:8IBJS6PWz3uTlMP3YBIR5f+KAldcGuOeFkFbUWfBgK4=
github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4=
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
golang.org/x/crypto v0.0.0-20211202192323-5770296d904e h1:MUP6MR3rJ7Gk9LEia0LP2ytiH6MuCfs7qYz+47jGdD8=
golang.org/x/crypto v0.0.0-20211202192323-5770296d904e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8 h1:/6y1LfuqNuQdHAm0jjtPtgRcxIxjVZgm5OTu8/QhZvk=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211208012354-db4efeb81f4b h1:MWaHNqZy3KTpuTMAGvv+Kw+ylsEpmyJZizz1dqxnu28=
golang.org/x/net v0.0.0-20211208012354-db4efeb81f4b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b h1:1VkfZQv42XQlA/jchYumAnv1UPo6RgF9rJFkTgZIxO4=
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211205182925-97ca703d548d h1:FjkYO/PPp4Wi0EAUOVLxePm7qVW4r4ctbWpURyuOD0E=
golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

モジュールをインストールしたのでDockerfileも少し追記します。

# ベースとなるDockerイメージ指定
FROM golang:latest

# コンテナログイン時のディレクトリ指定
WORKDIR /work

COPY ./src/go.mod /work
COPY ./src/go.sum /work

RUN go mod download

RUN apt-get update && apt-get install -y unzip

# Chrome
RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add && \
wget http://dl.google.com/linux/deb/pool/main/g/google-chrome-unstable/google-chrome-unstable_97.0.4692.20-1_amd64.deb && \
apt-get install -y -f ./google-chrome-unstable_97.0.4692.20-1_amd64.deb

# ChromeDriver
ADD https://chromedriver.storage.googleapis.com/97.0.4692.20/chromedriver_linux64.zip /opt/chrome/
RUN cd /opt/chrome/ && \
unzip chromedriver_linux64.zip

ENV PATH /opt/chrome:$PATH

各モジュールをインストールされたことによって作成されたgo.modgo.sumをコピーする記述と、
スクレイピング処理で使うためのChromeとChromeDriverをインストールする記述を追記しました。

ファイル追記

モジュールの追加ができたらserverディレクトリに入り、srcディレクトリの中にmain.goというファイルを作成してください。

$ cd server
$ mkdir src
$ vi main.go

main.goの中身はこんな感じです。

main.go
package main

import (
    "github.com/labstack/echo"
    "github.com/labstack/echo/middleware"
    "scraping/scraping"
)

func main() {
    // Echoのインスタンス作る
    e := echo.New()

    // 全てのリクエストで差し込みたいミドルウェア(ログとか)はここ
    e.Use(middleware.CORS())
    e.Use(middleware.Logger())
    e.Use(middleware.Recover())

    // ルーティング
    e.POST("/", scraping.GetMovieTheater)

    // サーバー起動
    e.Start(":8000")
}

echoというモジュールを使ってポート:8000でサーバを立て、
そのサーバにアクセスがあった場合別モジュールであるscraping.goのGetMovieTheaterというメソッドに移ります。
main.goではサーバだけ立てて、実際の処理はscraping.goに記載するということです。
ではそのscraping.goをserverディレクトリ配下にscrapingというディレクトリを作り、そのなかに作っていきます。

mkdir scraping
cd scraping
vi scraping.go

ファイルツリーを書くとこんなイメージです。

projects
├── client
├── server
  └── src
    └── main.go
    └── scraping
      └── scraping.go
└── docker-compose.yml

scraping.goの中身はこんな感じです。

scraping.go
package scraping

import (
    "fmt"
    "strings"
    "net/http"
    "github.com/PuerkitoBio/goquery"
    "github.com/djimenez/iconv-go"
    "github.com/labstack/echo"
    "github.com/sclevine/agouti"
)

func GetMovieTheater(c echo.Context) error {

    // TOHOシネマの劇場一覧サイト
    url := "https://www.tohotheater.jp/theater/find.html"

    // GETリクエスト
    res, _ := http.Get(url)

    // 呼び出し元の関数がreturnされるまで接続を切らない
    defer res.Body.Close()

    // 対象サイトのBody部分の読み取り
    utfBody, err := iconv.NewReader(res.Body, "Shift_JIS", "utf-8")

    if err != nil {
        fmt.Println("エンコーディングに失敗しました。")
        fmt.Errorf("Some context: %v", err)
        return c.JSON(500, map[string]interface{}{"error": "エンコーディングに失敗しました。"})
    }

    // HTMLパース
    doc, err := goquery.NewDocumentFromReader(utfBody)

    if err != nil {
        return c.JSON(500, map[string]interface{}{"error": "HTMLのパースに失敗しました。"})
    }

    // 入力された都道府県を取得
    place := c.FormValue("prefectures")
    
    theaterList := []string{}

    // 劇場一覧のHTMLから情報を取得
    doc.Find(".section > h1").Each(func(_ int, page *goquery.Selection) {
        // HTMLの中から劇場一覧の部分を指定
        if strings.Index(page.Text(), "劇場一覧") != -1 {
            // 劇場一覧の中から都道府県を指定
            page.Next().Find(".theater-list-area > h4").Each(func(_ int, prefectures *goquery.Selection) {
                // 入力された都道府県と一致する都道府県を探す
                if strings.Index(prefectures.Text(), place) != -1 {
                    // 指定した都道府県の劇場一覧のリンクを取得
                    prefectures.Next().Find(".item > a").Each(func(_ int, theaterLink *goquery.Selection) {
                        href, _ := theaterLink.Attr("href")
                        theaterList = append(theaterList, href)
                    })
                }
            })
        }
    })

    // JavaScriptを使った動的ページのHTMLを取得するためにChromeを利用することを宣言
    agoutiDriver := agouti.ChromeDriver(
        agouti.ChromeOptions("args", []string{"--headless", "--disable-gpu", "--no-sandbox", "--disable-dev-shm-usage"}),
    )
    
    if err := agoutiDriver.Start(); err != nil {
        fmt.Println("Failed to start driver:%v", err)
        return c.JSON(500, map[string]interface{}{"error": "agoutiDriver.Start()でエラー発生"})
    }

    defer agoutiDriver.Stop()
    page, err := agoutiDriver.NewPage()

    if err != nil {
        fmt.Println("NewPage()でエラー発生")
        fmt.Println("Some context: %v", err)
        return c.JSON(500, map[string]interface{}{"error": "NewPage()でエラー発生"})
    }

    // 入力された映画タイトルを取得
    title := c.FormValue("title")

    var theaterName []string
    var schedule [][]string

    // 都道府県に対応する劇場のサイトに入り上映状況を取得
    for _, theater := range theaterList {
        // 各劇場サイト
        url2 := "https://hlo.tohotheater.jp/" + theater

        // 各劇場サイトに入る
        page.Navigate(url2)

        // 動的ページのHTMLを格納
        dom, err := page.HTML()

        if err != nil {
            fmt.Println("page.HTML()でエラー発生")
            fmt.Println("Some context: %v", err)
        }

        // スクレイピングするためにDOMを読み込みなおす
        contents := strings.NewReader(dom)

        doc, err := goquery.NewDocumentFromReader(contents)
        if err != nil {
            fmt.Println("page.HTML()でエラー発生")
            fmt.Println("Some context: %v", err)
        }

        // 各劇場一覧ページのタイトル(映画館名)の「:」より前を格納
        theaterName = append(theaterName, doc.Find("title").Text()[:strings.Index(doc.Find("title").Text(), ":")])
        var time []string

        doc.Find(".schedule-body-section-item").EachWithBreak(func(_ int, page *goquery.Selection) bool {
            // 映画タイトルの中から入力されたタイトルと一致しているかを判別
            if strings.Contains(title, page.Find(".schedule-body-title").Text()) {
                // 入力されたタイトルと一致していれば上映スケジュールを取得
                page.Find(".schedule-item").Each(func(_ int, element *goquery.Selection){
                    text := element.Find(".start").Text() + "〜" + element.Find(".end").Text() + " " + element.Find(".status").Text()
                    time = append(time, text)
                })
                return false
            }
            return true
        })

        schedule = append(schedule, time)
        time = nil
    }

    result := map[int]map[string]interface{}{}

    for i := 0; i < len(theaterName); i++ {
        result[i] = map[string]interface{}{}
        result[i]["theaterName"] = theaterName[i]
        result[i]["schedule"] = schedule[i]
    }

    return c.JSON(200, result)
}

ちょっと一気に長くなってしまったので、順番に解説していきます。

対象の都道府県の映画館リンク一覧取得

    // TOHOシネマの劇場一覧サイト
    url := "https://www.tohotheater.jp/theater/find.html"

    // GETリクエスト
    res, _ := http.Get(url)

    // 呼び出し元の関数がreturnされるまで接続を切らない
    defer res.Body.Close()

    // 対象サイトのBody部分の読み取り
    utfBody, err := iconv.NewReader(res.Body, "Shift_JIS", "utf-8")

    if err != nil {
        fmt.Println("エンコーディングに失敗しました。")
        fmt.Errorf("Some context: %v", err)
        return c.JSON(500, map[string]interface{}{"error": "エンコーディングに失敗しました。"})
    }

    // HTMLパース
    doc, err := goquery.NewDocumentFromReader(utfBody)

    if err != nil {
        return c.JSON(500, map[string]interface{}{"error": "HTMLのパースに失敗しました。"})
    }

    // 入力された都道府県を取得
    place := c.FormValue("prefectures")
    
    theaterList := []string{}

    // 劇場一覧のHTMLから情報を取得
    doc.Find(".section > h1").Each(func(_ int, page *goquery.Selection) {
        // HTMLの中から劇場一覧の部分を指定
        if strings.Index(page.Text(), "劇場一覧") != -1 {
            // 劇場一覧の中から都道府県を指定
            page.Next().Find(".theater-list-area > h4").Each(func(_ int, prefectures *goquery.Selection) {
                // 入力された都道府県と一致する都道府県を探す
                if strings.Index(prefectures.Text(), place) != -1 {
                    // 指定した都道府県の劇場一覧のリンクを取得
                    prefectures.Next().Find(".item > a").Each(func(_ int, theaterLink *goquery.Selection) {
                        href, _ := theaterLink.Attr("href")
                        theaterList = append(theaterList, href)
                    })
                }
            })
        }
    })

ここでやっていることとしては

  1. TOHOシネマズの劇場一覧サイトにGetでアクセス
  2. TOHOシネマズの劇場一覧のBody要素を取得
  3. 2.で取得した要素について、Shift-JISで表示されており、スクレイピング用ライブラリgoqueryを使うためにutf-8に変換
  4. goqueryを使って要素の解析
  5. 要素内の劇場一覧に表示されている都道府県と、入力された都道府県が一致すればその都道府県のリンクを取得する

までです。これによって取得対象の映画館のリンク一覧が取得できました。

映画館のサイトに入り、空席情報を取得

    // JavaScriptを使った動的ページのHTMLを取得するためにChromeを利用することを宣言
    agoutiDriver := agouti.ChromeDriver(
        agouti.ChromeOptions("args", []string{"--headless", "--disable-gpu", "--no-sandbox", "--disable-dev-shm-usage"}),
    )
    
    if err := agoutiDriver.Start(); err != nil {
        fmt.Println("Failed to start driver:%v", err)
        return c.JSON(500, map[string]interface{}{"error": "agoutiDriver.Start()でエラー発生"})
    }

    defer agoutiDriver.Stop()
    page, err := agoutiDriver.NewPage()

    if err != nil {
        fmt.Println("NewPage()でエラー発生")
        fmt.Println("Some context: %v", err)
        return c.JSON(500, map[string]interface{}{"error": "NewPage()でエラー発生"})
    }

    // 入力された映画タイトルを取得
    title := c.FormValue("title")

    var theaterName []string
    var schedule [][]string

    // 都道府県に対応する劇場のサイトに入り上映状況を取得
    for _, theater := range theaterList {
        // 各劇場サイト
        url2 := "https://hlo.tohotheater.jp/" + theater

        // 各劇場サイトに入る
        page.Navigate(url2)

        // 動的ページのHTMLを格納
        dom, err := page.HTML()

        if err != nil {
            fmt.Println("page.HTML()でエラー発生")
            fmt.Println("Some context: %v", err)
        }

        // スクレイピングするためにDOMを読み込みなおす
        contents := strings.NewReader(dom)

        doc, err := goquery.NewDocumentFromReader(contents)
        if err != nil {
            fmt.Println("page.HTML()でエラー発生")
            fmt.Println("Some context: %v", err)
        }

        // 各劇場一覧ページのタイトル(映画館名)の「:」より前を格納
        theaterName = append(theaterName, doc.Find("title").Text()[:strings.Index(doc.Find("title").Text(), ":")])
        var time []string

        doc.Find(".schedule-body-section-item").EachWithBreak(func(_ int, page *goquery.Selection) bool {
            // 映画タイトルの中から入力されたタイトルと一致しているかを判別
            if strings.Contains(title, page.Find(".schedule-body-title").Text()) {
                // 入力されたタイトルと一致していれば上映スケジュールを取得
                page.Find(".schedule-item").Each(func(_ int, element *goquery.Selection){
                    text := element.Find(".start").Text() + "〜" + element.Find(".end").Text() + " " + element.Find(".status").Text()
                    time = append(time, text)
                })
                return false
            }
            return true
        })

        schedule = append(schedule, time)
        time = nil
    }

ここでやっていることとしては

  1. TOHOシネマズの各サイトがJavaScriptで動的に公開状況を表示しているため、JavaScriptを使った動的ページのHTMLを取得するためにChromeを利用することを宣言
  2. 画面から入力された映画のタイトルを取得
  3. 先の処理で取得していた映画館のリンクをもとに各映画館のサイトに入り、動的に表示されているDOMを取得
  4. goqueryで解析するために3で取得したDOMを読み込み直す
  5. 4で読みこんだDOMを解析し、入力されたタイトルと一致するタイトルを検索
  6. 5で一致するタイトルがあった場合、開始/終了時間と空席状況を取得

といった流れです。

取得した結果を整形してjson形式で返す

取得した映画館名、上映スケジュール、空席状況をまとめてjson形式にして返します。

    result := map[int]map[string]interface{}{}

    for i := 0; i < len(theaterName); i++ {
        result[i] = map[string]interface{}{}
        result[i]["theaterName"] = theaterName[i]
        result[i]["schedule"] = schedule[i]
    }

    return c.JSON(200, result)

これでサーバ側の実装が完成しました。

クライアント側での情報表示

サーバ側でリクエストを受けたらレスポンスをJSON形式で返す処理ができたので、
サーバ側にリクエストを投げる処理と取得した情報を返す処理を実装していきます。
まずデフォルトでさまざまな文言が表示されているApp.vueから不要な情報を削除します。

$ cd ../../client
$ vi src/App.vue
App.vue
<template>
  <div>
    <HelloWorld/>
  </div>
</template>

<script>
import HelloWorld from './components/HelloWorld.vue'

export default {
  name: 'App',
  components: {
    HelloWorld
  }
}
</script>

<style>
#app {
  font-family: Verdana,'游ゴシック体','Yu Gothic',YuGothic,sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>

実際の処理をHelloWorld.vueに追記します。

HelloWorld.vue
<template>
  <div>
    <form @submit.prevent="getTheaterList">
      <input type="text" v-model="prefectures" name="prefectures" placeholder="都道府県を入力してください"><br>
      <input type="text" v-model="title" name="title" placeholder="作品名を入力してください"><br>
      <button type="submit">submit</button>
    </form>
    <div v-for='(data, key) in results' :key="key">
      <h3 class="theater-name">{{ data.theaterName }}</h3>
      <div class="flex-container">
        <div class="flex-item" v-for='(schedule, key) in data.schedule' :key="key">
          <p>{{ schedule }}</p>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'HelloWorld',
  data() {
    return {
      results: ''
    };
  },  
  methods: {
    async getTheaterList() {
      const result = await this.sendRequest().then((res) => res.text());
      this.results = JSON.parse(result);
    },

    async sendRequest() {
      const url = 'http://localhost:8000';
      const data = new URLSearchParams();
      data.append("prefectures",this.prefectures);
      data.append("title",this.title);

      return fetch(url, {
        method: 'POST',
        headers: {
          'X-Requested-With': 'csrf', // csrf header
          'Content-Type': 'application/x-www-form-urlencoded',
        },
        body: data,
      });
    }
  }
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
h3 {
  margin: 40px 0 0;
}
ul {
  list-style-type: none;
  padding: 0;
}
li {
  display: inline-block;
  margin: 0 10px;
}
a {
  color: #42b983;
}
.flex-container {
  width: 100%;
  /* margin-left: 10%; */
  display: flex;
  flex-direction: row;
  background-color: rgba(20, 20, 20, 0.76);
}
.flex-item {
  width: 8%;
  margin: 20px;
  color: white;
  background-color: gray;
}
</style>

こちらも長くなったので順番に見ていきます。

<template>
  <div>
    <form @submit.prevent="getTheaterList">
      <input type="text" v-model="prefectures" name="prefectures" placeholder="都道府県を入力してください"><br>
      <input type="text" v-model="title" name="title" placeholder="作品名を入力してください"><br>
      <button type="submit">submit</button>
    </form>
    <div v-for='(data, key) in results' :key="key">
      <h3 class="theater-name">{{ data.theaterName }}</h3>
      <div class="flex-container">
        <div class="flex-item" v-for='(schedule, key) in data.schedule' :key="key">
          <p>{{ schedule }}</p>
        </div>
      </div>
    </div>
  </div>
</template>

ここでやっていることは都道府県と作品名のsubmitと、受け取った情報の表示です。

<script>
export default {
  name: 'HelloWorld',
  data() {
    return {
      results: ''
    };
  },  
  methods: {
    async getTheaterList() {
      const result = await this.sendRequest().then((res) => res.text());
      this.results = JSON.parse(result);
    },

    async sendRequest() {
      const url = 'http://localhost:8000';
      const data = new URLSearchParams();
      data.append("prefectures",this.prefectures);
      data.append("title",this.title);

      return fetch(url, {
        method: 'POST',
        headers: {
          'X-Requested-With': 'csrf', // csrf header
          'Content-Type': 'application/x-www-form-urlencoded',
        },
        body: data,
      });
    }
  }
}
</script>

ここでは非同期通信を使って http://localhost:8000 へリクエストをPOSTし、受け取ったJSONを配列へ変換しています。

<style scoped>
h3 {
  margin: 40px 0 0;
}
ul {
  list-style-type: none;
  padding: 0;
}
li {
  display: inline-block;
  margin: 0 10px;
}
a {
  color: #42b983;
}
.flex-container {
  width: 100%;
  /* margin-left: 10%; */
  display: flex;
  flex-direction: row;
  background-color: rgba(20, 20, 20, 0.76);
}
.flex-item {
  width: 8%;
  margin: 20px;
  color: white;
  background-color: gray;
}
</style>

こちらでは見た目の変更のためにCSSを追記しています。

結果

serverコンテナに入り、main.goを実行することでAPIサーバが起動します。

$ docker-compose exec server bash
$ go run main.go

   ____    __
  / __/___/ /  ___
 / _// __/ _ \/ _ \
/___/\__/_//_/\___/ v3.3.10-dev
High performance, minimalist Go web framework
https://echo.labstack.com
____________________________________O/_______
                                    O\
⇨ http server started on [::]:8000

あとはクライアント側で都道府県名、作品名を入力して送信すればOKです!

スクリーンショット 2021-12-12 15.49.00.png

このような形で、東京都の映画館の上映状況が取得できるようになりました!

あとがき

今回はDocker + Vue.js + Go言語で映画館の空席状況の取得に挑戦しました。
正直なところ、本当はローカル環境での実行ではなくどこかの環境にデプロイしたり、
TOHOシネマズ以外の映画館の情報も取得したり、
画面のデザインももう少しこだわりたかったりしたのですが記事の執筆に間に合わなさそうだったので今回はここまでとします。
不明点や「ここはこうしたほうがいい」などあればコメントいただけると助かります。
最後まで読んでいただきありがとうございました。

参考

20
6
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
20
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?