孤独のグルメSeason8は中華で始まりましたね。中華料理店の時代です。
ところで、勤め先の会社に無類のエビ卵好きがいまして、お客先等に行く時の昼ごはんは、必ず中華屋でエビ卵炒め定食を食べています(先日は週5日エビ卵コンプリートしたと言って喜んでいました)。
変わり者だなぁと思っていたのですが、この前配属された新人ちゃんもエビ卵大好きと判明。
もしかして、エビ卵市場はとてつもなく大きいかもしれない!
ということで作成したWebアプリが、エビ卵特化型中華料理店マップ。
えびたまっぷ
以下、作り方と課題などについて記したいと思います。
システム基盤
開発フレームワーク(言い方が古い?アーキテクチャ?)に何を使うかという話。
このアプリではWordPressサイトを立てて、ページテンプレートとしてアプリを作り、このページテンプレートを読み込む形で固定ページを作る、という方法を採っています。
HeadlessCMS ならぬ HeadonlyCMS
最近はHeadlessCMSという考えを目にしますが、それと真逆の方法です。
すなわち、HeadlessCMSは、コンテンツ作成のしやすさの部分でCMSを活用し、フロントのデザインや機構については独自に作り込もうという話かと思います。
しかし、私の場合、デザインがどうにも苦手、という弱点があり、その部分をカバーする方法として、CMS(のテーマ)を活用したい、という思いがあります。コンテンツの内容はアプリとして作成してしまうので、CMSの機能は全く使わないのですが、ヘッダ・フッタの読み込みや、全体の統一感を出すためのCSSの活用等が可能になります。
さらにもう一つの理由として、アプリを書く際にWordPress内で定義されている関数が使えるということもあります。
関数リファレンス
と言いつつ、実は、今回は、デザインはほとんどテーマの定義は使用しませんでした。
参考にさせていただいた情報で綺麗なCSSが提供されていたからです。
ただ、このアプリの前に作った別のアプリでは、テーマの定義を活用しています。
タイタニック チャレンジ
テーマのデザインを生かしつつ、作り込む部分のデザインもしやすいように、Bootstrap対応をうたっているLightningを使用しました。
以上の、WordPress基盤の使用についは、こちらにも、書いています。
Watson Machine Learning を活用してWordPress サイト内にアプリ作成
WordPressでのページテンプレートの作り方
直接テーマファイルに手を入れるのは推奨されておらず、子テーマを作ります。
子テーマの作り方は、WordPressとして定まっていますが、Lightning
の場合は子テーマ用のデータが提供されているので、それを使用します。
wp-content/themes/lightning
のようなフォルダ構成になっていると思いますので、それと並べて、子テーマディレクトリを作成します。私の場合は今回は、
wp-content/themes/jqapp
となります。そして、その中には、style.css、functions.php、_sidebar-event.php、_module_loop_event.php が含まれる形となっています。
ここに、アプリ用のファイル、ebitamago.php を作成します。
その先頭に、
<?php
/**
* Template Name: ebitamago
*/
?>
を書くことで、テンプレートとして認識され、WordPress管理画面で固定ページを作成する際の「テンプレート」の選択肢に「ebitamago」表示されるようになります。
仮に、ebitamago.php の内容がこれだけの状態で、テンプレートとしてebitamagoを洗濯した固定ページを表示すると、何も表示されません。
若干追記して、
<?php
/**
* Template Name: ebitest
*/
?>
<?php get_header(); ?>
<?php get_template_part( 'module_pageTit' ); ?>
<?php get_template_part( 'module_panList' ); ?>
とすると、ヘッダ部、タイトル、パンくずリストがそのテーマ定義に従って表示されます。
さらにその下に、フォームを作ったり、リストを作ったりするわけですが、テーマによって、CSSの階層構造を見たりしているので、それは、そのテーマの標準に従う必要があります。
lightningで言えば、
wp-content/themes/lightning/page.php
あたりを参照すれば、どのような構成になっているかがわかると思うので、必要な部分をコピーしたりします。
上記のように、今回はあまりテーマを生かしたデザインにはしなかったものの、構成はそれなりに真似して、下記のような形にしました。
<?php
/**
* Template Name: ebitest
*/
?>
<?php get_header(); ?>
<div class="section siteContent">
<div class="container">
<div class="row">
<div class="col-md-12 mainSection" id="main" role="main">
ここにアプリのコントロール等を配置
</div><!-- [ /.mainSection ] -->
</div><!-- [ /.row ] -->
</div><!-- [ /.container ] -->
</div><!-- [ /.siteContent ] -->
店舗情報の取得
今回やりたいのは、まず、現在地の近くに存在する中華屋さんの情報を地図上に表示することです。
日本の飲食店情報のデータベースといえば、やはり、ぐるなび、ホットペッパー、食べログあたりを思いつきますね。
それらのAPIを使用させていただくことを検討します。
まず、食べログはAPIの提供を停止してしまったようです。
次に検討したのは、ホットペッパーAPIです。
ホットペッパー | APIリファレンス | リクルートWEBサービス
このページを見てもわかるように、リクルートが提供してくれているAPIは多数あります。以前、別のAPIも使ったことがあったので、最初は、こちらで作り始めました。
が、思った店が検索されません。
思うに、ホットペッパーは、予約できる店にかなり特化しているように思います。そのため、町中華のような店が入っていないのではないかと思われます。
そこで、今回は、ぐるなびのAPIを使わせていただくことにしました。
そして、ぐるなびAPIによる店舗情報の取得と、Google Map への表示については、下記を全面的に参照させていただきました(以下、「参考サイト」)。
【javascript】現在地の近くのWifiのあるカフェを探すアプリ
ぐるなびAPI
レストラン検索APIの利用自体については、APIテストツールなども提供されていたりして、さほど難しくないと思うので割愛します。
要は、以下のURL文字列を呼び出します。
"https://api.gnavi.co.jp/RestSearchAPI/v3/?keyid=(自身のアクセスキー)&latitude="+lat+"&longitude="+lng+"&hit_per_page=30&range=2&category_l=RSFST14000"
RSFST14000というのが、中華料理店カテゴリになります。1回の呼び出しで30件まで取得します。デフォルトの10件だと少なく感じるのですが(かくも中華料理店は多い)、一気に30件以上表示させても見るのも大変でしょうから。
問題は、これをどう呼び出すかです。
WordPressアプリ(ページテンプレート)からのjQueryのAjax
本件参考サイトでは、jQueryのAjax呼び出しを使用しています。下記は一部抜粋ですが、全体ソースは参考サイトをご覧ください。
$.ajax({
type : "get",
url : "https://api.gnavi.co.jp/RestSearchAPI/v3/?keyid=【ぐるなびアクセスキー】&wifi=1&latitude="+lat+"&longitude="+lng+"&range=4&category_l=RSFST20000,RSFST18000",
dataType : 'json',
success : function(json){
ただ、この$.ajax、WordPress内ではそのままでは使えません。
jQuery(document).ready(function ($) {
の中に入っている必要があります。ここは情報探すのに時間がかかりました。
###GoogleMap への表示
地図の表示には、Google Maps API を使用します。
取ってきた情報に緯度・経度も含まれるので、それを使用して、マーカーを立てていきます。
この辺は、ほとんど参考サイトのまま使わせていただいていますが、いくつか改変しました。
まず、地図移動に対応させます。参考サイトでは、現在地の情報を表示させるのみで、地図を移動してそのエリアで再度検索ということはしていませんが、その機能をつけたいと思いました。
移動の検出は、色々調べてみると、idleイベントが良さそう。
これの書きどころは、非常に苦戦しましたが、
map.addListener('idle', function(e) {
if (map.getCenter() === undefined){
return;
} else {
set_map(map.getCenter().lat(), map.getCenter().lng());
};
});
を、jQuery(document).ready(function ($) {
のブロックの最後に書きました。
ポイントは、map自体を非同期で作成しようとしているので、mapが作成される前にmap.addListener を呼び出すことはできない、というところでしょうか。ですので、非同期処理の最後にこれを行う、という形です。
なお、set_map
というのは、参考サイトのget_pos
の内容を切り出して別functionにしたものです。その中で、ぐるなびAPIを呼び出して、地図にマーカーを立てて、店リストを作る、ということをやっています。
また、マーカーをクリックした際に、参考サイトではページ遷移する形ですが(現在地のみの表示ならそれで良いわけですが)、別ウインドウを立ち上げる形(window.open)にしました。地図を移動して、移動先のマーカーをクリックした後で、戻ってきたらまた現在地の表示になってしまうとちょっとがっかりなので。
google.maps.event.addListener(marker, 'click', (function(url){
return function(){ location.href = url; };
})(json.rest[i].url));
google.maps.event.addListener(marker, 'click', function() {
window.open(json.rest[i].url);
});
場所検索機能
今現在(2019年10月)熱い(?)キャッシュレス・ポイント還元制度。対象店を探す経済産業省のアプリが話題になりました。ブラウザ版はこちらです。
が、これが残念なことに、場所検索機能がついていない。初期表示で現在地が表示されますが、別の場所の店を探そうと思うと、一生懸命地図をドラッグさせていかないといけない。それはつらいよ・・・
と、言いながら、本アプリも最初に冒頭のエビ卵好きさんに見せたときのバージョンではそうなっていました。恥ずかしい汗をかきながら、すかさず場所検索機能を追加しましたので、是非経済産業省におかれましても、ご検討ください。
基本的には、Places Search Boxの使い方に従うのみです。
まずは、地図領域の直前が分かりやすいと思いますが、inputタグを置きます。下記の、id="pac-input"
です。
<!-- Map appears here -->
<div id="content">
<div class="title">
<img src='https://jqselect.sakura.ne.jp/apps/wp-content/uploads/2019/10/エビ卵-MAP.png' />
</div>
<input id="pac-input" class="controls" type="text" placeholder="駅名等で検索..." style="width:100%">
<div id="map" class="map"></div> <!--mapを表示-->
<ul id="shop-list"></ul> <!--お店の詳細情報を表示-->
</div>
<script defer src="https://maps.googleapis.com/maps/api/js?key=(APIキー)&libraries=places&callback=initMap">
</script>
ここで、google maps api を読み込んでいる最後の部分で、コールバック処理として、initMapを定義しています。
その処理の中で、地図とinputを結び付けるわけです。
var map;
var markers;
function initMap() {
// Set the default location and initialize all variables
map = new google.maps.Map(document.getElementById('map'), {
// center: pos,
zoom: 15,
mapTypeControl: false
});
markers = new google.maps.MVCArray();
var input = document.getElementById('pac-input');
var searchBox = new google.maps.places.SearchBox(input);
// Listen for the event fired when the user selects a prediction and retrieve
// more details for that place.
searchBox.addListener('places_changed', function() {
var places = searchBox.getPlaces();
if (places.length == 0) {
return;
}
// Clear out the old markers.
markers.forEach(function (marker, idx) { marker.setMap(null); });
markers = [];
// For each place, get the icon, name and location.
var bounds = new google.maps.LatLngBounds();
places.forEach(function(place) {
if (!place.geometry) {
console.log("Returned place contains no geometry");
return;
}
// Create a marker for each place.
markers.push(new google.maps.Marker({
map: map,
// icon: icon,
// title: place.name,
position: place.geometry.location
}));
if (place.geometry.viewport) {
// Only geocodes have viewport.
bounds.union(place.geometry.viewport);
} else {
bounds.extend(place.geometry.location);
}
});
map.fitBounds(bounds);
});
##メニュー情報の取得
色々書きましたが、ここまでは、ある意味本件参考サイトの微修正レベルではあります。
これから書くメニュー情報の取得・表示こそがこのアプリの独自の部分になります。
そのお店に、エビ卵のメニューが存在するか、残念ながらAPIではメニュー情報は提供されていません。
そこで、スクレイピングさせていただくことにします。
そう思った場合に、ぐるなびサイトは、ある程度メニューページが構造化されていて助かります(ただし、ある程度ですが)。
各店舗のページは、
https://r.gnavi.co.jp/(店舗ID)
という形になっています。
その下に、menu1、menu2・・・というページがあります。ただし、これは何ページ目まであるか分かりません。
メニューページでは、各料理が<li class="menu-item ・・・>
または<li class="menu-vitem ・・・>
というリストで列挙されます。画像付きの場合はv-itemのようです。
そのリストの中で、<dt class="menu-term">
の中に料理名が記載され、さらに下記のような構造の中に価格が記載されます。
<li class="menu-vitem cx ">
<dl>
<dt class="menu-term">
エビと玉子の炒め
</dt>
<dd class="menu-img t4">
<a href="//uds.gnst.jp/rest/img/gaamzees0000/s_0n7d.jpg?t=1495128155" class="gallery cboxElement" title="エビと玉子の炒め">
<p class="figure"><img src="//uds.gnst.jp/rest/img/gaamzees0000/t_0n7d.jpg?t=1495128155&g=157" width="157" height="157" alt="エビと玉子の炒め" title="エビと玉子の炒め"></p>
<span class="gallery-zoom"><span class="icon-zoom"></span></span>
</a>
</dd>
<dd class="menu-desc"><ul class="menu-plan-icons cx"></ul></dd>
<dd class="menu-price">
<table><tbody>
<tr>
<th></th>
<td>880円<span>(税込)</span></td>
</tr>
</tbody></table>
</dd>
</dl>
</li>
ここから写真、名前、価格を取り出します。
###CORS回避のためのAPI化
まず、方式として、上記で情報を取得した各店舗のメニューを取得するので、Javascriptでスクレイピングをすることになります。
Javascriptでリアルタイムでスクレイピングする方式は、下記を参考にさせていただきました。
jQuery.ajaxでWebスクレイピングを実装してみた - メン醤のjQuery workshop
基本的にはそれで取得できることは確認できたのですが、ぐるなびページを取得しようとすると失敗します。CORSの話です。これ自体の詳細は割愛します。
上記のサイトで乃木坂のページを取得できているのは、乃木坂ページがCORS対応しているからとしか考えられませんが、そういうものなのでしょうか??
で、ぐるなびが対応していない以上、こちらからはどうしようもないということで、他の方法を考えたりもしましたが、結論としては、今アプリを作っているWordPressサイト内に、もう一つAPIアプリを立てる。これにより、本アプリは同一ドメインのAPIにアクセスする形を取れます。
そして、APiアプリが、実際にスクレイピングを行うという構成にしました。
上記のように、
wp-content/themes/jqapp
に、アプリ自体のテンプレートファイルであるebitamago.phpを作成していますが、これと並べて、ebitamago-api.phpを作成します。
phpでのスクレイピング
このebitamago-api.phpの中で、メニューページの内容を取得します。
phpでwebページの情報を取得する方法として、phpQueryが簡単そうということで、こちらなどを参考にさせていただきました。
phpQuery-onefile.phpが提供されているので、これを参照すれば良いわけですが、WordPressページから呼び出すには
require_once(dirname(__FILE__)."/phpQuery-onefile.php");
とする必要があります。
ebitamago.phpからリクエスト引数として店舗IDを渡す形として、api側では、その店舗IDのページのメニューページを呼び出します。上記のようにメニューページが何ページあるかわからないので、menu1からmenu5でループします。こういう無駄なアクセスは申し訳ないのですが。しかも、画像の有無でmenu-itemかmenu-vitemかが変わるということで、両方の取得を試みます。ダサいですけど、すみません。
不要ですが、参考ページの痕跡もコメントで残しています。
require_once(dirname(__FILE__)."/phpQuery-onefile.php");
$restid = $_GET["restid"];
$ebitamago = [];
for($val = 1; $val <= 5; $val++){
$html = file_get_contents("https://r.gnavi.co.jp/".$restid."/menu".$val."/");
$doc = phpQuery::newDocument($html);
foreach($doc[".menu-vitem"] as $row)
{
$name = trim(pq($row)->find(".menu-term")->text());
if( (strpos($name, 'えび') !== false || strpos($name, 'エビ') !== false || strpos($name, '海老') !== false || strpos($name, '蝦') !== false )
&& (strpos($name, 'たまご') !== false || strpos($name, '卵') !== false || strpos($name, '玉子') !== false || strpos($name, 'タマゴ') !== false )){
//各要素取得
$key = trim(pq($row)->find(".menu-term")->text());
$value = 'https:' . pq($row)->find("img")->attr("src");
//表示
// echo $key . "-" . $value . "<br>";
// echo pq($row)->text();
$arr["menu_term"] = trim(pq($row)->find(".menu-term")->text());
$arr["img_src"] = pq($row)->find("img")->attr("src");
$arr["menu_price"] = trim(pq($row)->find(".menu-price")->text());
array_push($ebitamago, $arr);
}
}
foreach($doc[".menu-item"] as $row)
{
$name = trim(pq($row)->find(".menu-term")->text());
// echo $name;
if( (strpos($name, 'えび') !== false || strpos($name, 'エビ') !== false || strpos($name, '海老') !== false || strpos($name, '蝦') !== false )
&& (strpos($name, 'たまご') !== false || strpos($name, '卵') !== false || strpos($name, '玉子') !== false || strpos($name, 'タマゴ') !== false )){
$arr["menu_term"] = trim(pq($row)->find(".menu-term")->text());
$arr["img_src"] = pq($row)->find("img")->attr("src");
$arr["menu_price"] = trim(pq($row)->find(".menu-price")->text());
array_push($ebitamago, $arr);
}
}
}
$json = json_encode($ebitamago);
// JSON用のヘッダを定義して出力
header("Content-Type: application/json; charset=utf-8");
echo $json;
exit();
これを作って、また固定ページのテンプレートに設定して固定ページをebitamago-apiとしておけば、アプリ側では下記のような呼び出しを行って情報を取得することができます。
var getEbitamago = (function(restid) {
var menuurl = '../ebitamago-api/?restid='.concat(restid).concat('');
$.ajax({
type: 'GET',
url: menuurl,
}).done(function (data) {
if (data == null){
return;
}
ebitamagoOgj = data;
$li = '';
ebitamagoOgj.forEach(ebitama => {
$li = $li + '<span class="ebitamadata">';
if (ebitama.img_src != '') {
$li = $li
+ '<img class="ebitamago-img" src=' + ebitama.img_src + '>';
}
$li = $li
+ '<span class="ebitamago-name">' + ebitama.menu_term + '</span>'
+ '<span class="ebitamago-price">' + ebitama.menu_price + '</span>';
$li = $li + '</span>';
});
$("#ebitamago" + restid + "").html($li);
}).fail(function(jqXHR, textStatus, errorThrown){
console.log('ajax fail' + jqXHR.status);
});
});
スクレイピングを行う際の注意事項
ところで、スクレイピングを行う場合の注意事項として、こちらがよく参照されています。
Webスクレイピングの注意事項一覧
相手側サイトの負荷に配慮するため、及び、法律違反を回避するためにまとめていただいているもので、他にあまり情報がないために貴重な情報となっていると思います。
その中でちょっと気になったのが、「サーバアクセスの間隔を1秒以上空けるようにする。」という記述です。
これに関しては、このような情報もありました。
Webスクレイピングする際のルールとPythonによる規約の読み込み
要するに、1秒だろうが何秒だろうが、相手サーバに負荷をかけない注意が必要ということかと思います。
ですので、まずは、負荷をかけないようにapiがメニューページを見に行く間隔を開けるようにします。
方法は、こちらを参考にさせていただきました。
JQuery.Deferredでwait風メソッド
これをやっておいて、先ほどのapi呼び出し用のgetEbitamagoファンクションを、こんな感じで呼び出すようにしました。
$.wait(i*500).done(function() {
getEbitamago(json.rest[i].url.substring(22, json.rest[i].url.indexOf('?') -1));
});
22とか直書き・・・そして、自分の都合の良いように解釈して、0.5秒ごとのアクセス間隔(しかもその中でメニューページ5回アクセスしている)。
robot.txtに関しては、ぐるなびは提供していないように思うのですが、いかがでしょうか?(そんなこともないのでは、とも思いますが、お探しのページは見つかりませんになってしまうので)
完成
以上で出来上がると思います。ebitamago-api.phpの方はほぼ全部ですが、ebitamago.phpの方はあれこれ汚いので、つぎはぎの抜粋ですみません。
##ハマったことや課題など
も、書こうと思ったのですが、たいがい長くなってるし、一回公開してみたいので、一旦締めます。
その辺はブログの方か、追記するか、追々考えますということで。