🍣

沖縄で地域クーポンが使えるお店をGoogleマップで表示するために無理やりdartでスクレイピングをしてみた

2022/12/10に公開

2022 年 12 月 10 日の Flutter 大学アドベントカレンダーを担当させていただきます、こんぶです。

https://qiita.com/advent-calendar/2022/flutteruniv

ないならてめえで作れの精神

2022 年 11 月 26 日に Flutter 大学の沖縄オフ会が開催されました。Flutter 大学では 2 ヶ月に 1 回のペースで各地方でオフ会を開催しています。リアルでの会うことの力を信じているからです。

そこでせっかくなら、現在開催中の全国旅行支援をつかってお得に旅を楽しもうよということになりました。このキャンペーンでは、宿代が 40 パーセント割引され、平日に泊まれば 3000 円の地域クーポンが入手できます。

この地域クーポンは沖縄県の加盟店で使用することができます。その加盟店の一覧は「おきなわ彩発見 NEXT」という特設サイトで閲覧が可能です。

https://okinawasaihakkennext.com/coupon.html

このサイト、絞り込み検索や、フリーワード検索など、検索は充実しているのですが、地図上に表示する機能がありません。ぼくは普段 Google マップを活用して旅を楽しんでいます。なので、Google マップを見ながら地域共通クーポンが使えるかを確認できたら嬉しいなと思いました。

わたしたちはエンジニアなので「不満ばっかり言ってるやつはださい、ないならてめえで作れ」精神が根付いています。というより、リトルこんぶがそうささやいてきたのです。

作ったらぼくも嬉しいし、ぼく以外のひとも喜んでくれそう。こういう課題はやった方がいいので、やることに決めました。

技術選定

課題を解決するために調査と技術選定をしていかなくてはなりません。

まずは Google マップをつくるための方法を探りました。調査はそれがダイレクトに課題に寄与するところから始めた方がいいです。

こういうとき「どうせスプレッドシートからデータを読み込んでそれを Google マップにマッピングしてくれるスクリプトを誰かが書いてくれてるだろうなあ」という目星をつけて検索していくとうまくいきます。

たぶん、この機能は誰かが作ってくれているという姿勢は大事です。

調査開始後すぐにこの記事を発見しました。

https://tonari-it.com/google-mymap-autogenerated/

どうやら公式がそのような機能を用意してくれているようで、スプレッドシートのみ用意すればマップは作ってくれるようです。

ほなら次はそのスプレッドシートをどう作るかという話になります。おきなわ彩発見 NEXT が一覧テーブルを用意してくれていればいいのですが、そのようなデータを見つけることはできませんでした。

WEB ページからスクレイピングをしてくることを考えます。

最近のトレンドはわからないのですが、スクレイピングをちゃちゃっとやるなら python が主流だったように思います。ただ自分たちは Flutter 大学なので、せっかくなら dart を使ってスクレイピングができんものかいのーと思い、調査を進めていきました。

検索するとすぐにたくさんの先人がいらっしゃることがわかり、これはおそらく最後まで走り切れるだろうと判断し、コードを書いていく決意を固めます。

dart でスクレイピング

dart ファイルを実行する

Flutter を勉強している方であれば環境構築は不要です。おそろしく簡単に実行できます。

どこか適当なディレクトリに dart ファイルを作成してください。

main.dart
void main() {
  print('hello, world!');
}

同じディレクトリ内で

dart main.dart

と入力するだけで実行できます。簡単!

dart プロジェクトを作成する

ちゃんとするなら dart プロジェクトを作った方がいいです。これは Flutter 同様に

dart create プロジェクト名

で作成することができます。

スクレイピング

今回スクレイピングする対象は、沖縄の地域クーポンが使用できるお店の名前と住所のセットです。これらを見つけに行きます。

対象となる URL はこちらになります。

https://okinawasaihakkennext.com/coupon.html?search=&offeset=0

アクセスしていただくと、お気づきのことが2点あると思います。

まず 1 点目は 9 件ずつしかデータを取得できないということ。
2点目はアクセス時に一瞬待って結果が表示されるということ。

この2点に注意しながら開発を進めていかねばなりません。

1点目の解決は簡単で、offset というクエリパラメータがありますので、これを 9 ずつ増加させていくことで対応可能です。

2 点目は Web スクレイピングに慣れている方ならすぐにわかるのだと思うのですが、ぼくは素人で苦労しました。

この一瞬待つというのはつまり、ブラウザで JavaScript が実行されてサーバーがからデータを引っ張ってきているということのようです。単純に http でページを get するだけだとうまくいかないということですね。

解決するためにはブラウザで実行して、読み込まれるのを待つ必要があるわけですが、これをやってくれるのが puppeteer というパッケージです。

    await myPage.goto(
        'https://okinawasaihakkennext.com/coupon.html?search=&offset=$offset',
        wait: Until.networkIdle);

この wait: Until.networkIdle というのが重要で、このように書くだけで、データを読み込むまで待ってくれます。

完成したコードがこちらです。

import 'dart:convert';
import 'dart:io';

import 'package:collection/collection.dart';
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:puppeteer/puppeteer.dart';

void main(List<String> arguments) async {
  /// 9件ずつしか取得できないためオフセットをずらしながら全件取得する
  int offset = 0;

  /// ここに一行分の文字列データを配列で格納していく
  final results = [];

  final browser = await puppeteer.launch();
  final myPage = await browser.newPage();

  while (true) {
    await myPage.goto(
        'https://okinawasaihakkennext.com/coupon.html?search=&offset=$offset',
        wait: Until.networkIdle);

    /// これで現在表示中の html を取得できる
    final html = await myPage.evaluate('document.documentElement.innerHTML');

    /// 今回は html パッケージをつかってパースしていく
    final document = parse(html);

    /// 対象となるクラスネームを指定して要素を取りに行く
    final elements = document.getElementsByClassName('col-lg-4 col-sm-6');

    if (elements.isEmpty) {
      break;
    }

    offset += 9;

    for (final e in elements) {
      final name = e.getElementsByClassName('card-title').first.text;
      print(name);
      final detailsUrl =
          e.getElementsByTagName('a').first.attributes.values.first;

      /// 住所の取得は詳細ページから行うため別ページのデータを取得する
      final response = await get(Uri.parse(detailsUrl), headers: {
        'Content-Type': 'text/html; charset=utf8',
      });

      /// ここで住所データを取得している
      final details =
          parse(utf8.decode(response.bodyBytes)).getElementsByTagName('td');
      final res = details.first.nodes
          .where((element) => element.text?.contains('〒') == true);
      final address = res.firstOrNull?.text?.replaceAll('\n', ' ') ?? '';
      results.add('$name,$address');
    }
  }

  /// 最後に csv ファイルとして書き出す
  File('shops.csv').writeAsBytes(utf8.encode(results.join('\n')));

  // Gracefully close the browser's process
  await browser.close();
}

並列処理を頑張ったカスタム版もあるのですが、今回はシンプルな while 文の形で公開します。

これを実行してもらうと同フォルダ内に shops.csv というデータが吐き出されるので、これをもとに Google マップを作成すれば今回の課題は解決となります。

作成方法は上記で挙げたこちらの記事を参考にしてください。

作ったものがこちらです。

https://www.google.com/maps/d/u/0/edit?mid=1lsSq13mq6Yl-w9ICv2wLjR7Fg2yJdhs&ll=29.37177907945795%2C129.2535488&z=6

ツイートもしたのですが、思ったより使われなかったのでここで宣伝します。

https://twitter.com/pressedkonbu/status/1596830677416411136?s=20&t=OzTYi6xKMfN5Q_G1dA-PIw

沖縄旅行に行くときはぜひご活用ください!

Discussion