Vue.js初心者がWP REST APIとGA Reporting APIを使ってスワブロランキングアプリを作ったよ
2020.04.01
ども、むったんです。
コロナ騒ぎで、ライブ&イベントが軒並み中止になってとても辛い。
えぇ、分かってます。
ウイルスを蔓延させないためって分かってます。
分かってる…けどさぁーーー!推しに会いたいよーーー😭
ちなみに3月に開催予定だったライヴは中止となり、払い戻しが始まりました😭
ライブが開催できていれば、リアル推し×画面の中にいた2D推しが3Dになってステージで歌って、踊っていたと思うと…是非もう一度企画していただきたいと思う所存!
むったんが行きたかったライブはこちら
そして、そんな悶々とした気持ちをぶつけながら作ったのが、今回のアプリですww
スワブロランキングアプリを作った
作り始めた経緯
みなさんが見てくれているこのスワローブログ(略してスワブロ)ですが、運用が始まったのは2018年10月。
そこから月ごと&四半期ごとに、中村さんがGoogleアナリティクスからフィルターかけたりしてランキングを発表してくれていました。
正直この作業簡略化できそうだし、アプリにできたら中村さんの仕事の負担ちょっとは減るよなー…と思っていました。
なので、リーダー千に「中村さんの仕事の負担を減らすために、WPとアナリティクスからデータ引っ張ってきてスワブロランキングアプリ作ってええ?( ´ ▽ ` )ノ」とノリで言ったらOKをもらいました!
ということで、ざっくり書いていくよ😆
環境
- WordPress 5.3.2
- GoogleアナリティクスReporting API v4
- Vue.js 2.6.10
- vuex 3.1.0
- vuetify 2.1.0
取得したいもの
GoogleアナリティクスReporting API v4
- URL
- PV数
WP REST API
- URL
- 投稿日
- ブログタイトル
- 投稿者
投稿者情報が不要であれば、GoogleアナリティクスReporting APIのみで行けたのですが…そりゃそうね、複数人で運用しているブログだもの。
投稿者は知りたいよね。
インストールしたもの
- vuetify
詳しい導入方法は以前むつたくが紹介しているので、そちらをご覧ください。
やっぱりフレームワーク使うとラクだわ(諦めれば、cssとの戦いも放棄できると最近踏ん切りがついたw
Vue-CLI3から始めるUIフレームワーク 〜Vuetify〜
- moment.js
インストール方法は以下の記事が分かりやすいです。
Vue.jsで日付処理ライブラリMoment.jsを使う|WEB PIXEL
- vue-monthly-picker
年月だけ指定したかったので、以下のものを使用させていただきました🙏
- vue-google-api
Google ApiとGoogle認証を使用して、クライアント側の操作を行うのに必要なもの。
ざっくり流れはこんな感じ
WP REST APIは投稿順で情報をくれるっぽい。
GA Reporting APIは好きなようにソートできるっぽいので、PV昇順に指定。
for文で回して、GA Reporting APIから来た内容を基準にWP REST APIの情報もまとめて、ランキングを表示しました。
準備(WordPress)
WordPress4.7以上であれば、プラグインなどを使用せずに下記URLから記事情報等がjson形式で取得できます。
1 | http://[ドメイン]/wp-json/wp/v2/[slug] |
ちなみに、今回はスワブロの情報を取得したいので、こんな感じになります。
1 | https://swallow-incubate.com/wp-json/wp/v2/blog |
取得件数の初期値は10件です。
準備(Google API)
設定が面倒だったのはこっちだった…orz
そして未だちゃんと理解してないけど、動いてるから大丈夫なのかな😅
ざっくり説明すると、こんな感じ。
- Google API ConsoleからGoogle Analytics Reporting APIを選択
- プロジェクトを作成
- 認証情報を作成
- Google Analyticsにサービスアカウントを追加
- APIキーを発行
- OAuth2.0クライアントIDを発行(むったんはhttp://localhost:8080を許可、状況に応じて変更してください)
詳しいやり方はSAKI Web Design様のブログに書いてありますので、参考にしてみてください🙏
Google Analytics Reporting API V4 を使う①|SAKI Web Design
コード
※コードが長くなるので、CSSは書きません。
src/main.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | import Vue from 'vue' import App from './App.vue' import router from './router' import store from './store/index' import vuetify from './plugins/vuetify' import VueGoogleApi from 'vue-google-api' const config = { clientId: '[clientId]', scope: 'https://www.googleapis.com/auth/analytics', apiKey: '[apiKey]', discoveryDocs: ['https://analyticsreporting.googleapis.com/$discovery/rest?version=v4'] } Vue.config.productionTip = false Vue.use(VueGoogleApi, config) new Vue({ router, store, vuetify, render: h => h(App) }).$mount('#app') |
src/App.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | <template> <v-app> <BaseLine /> <v-content> <router-view /> </v-content> </v-app> </template> <script> import BaseLine from '@/components/BaseLine.vue' export default { name: 'App', components: { BaseLine }, data: () => ({ // }), }; </script> |
src/components/BaseLine.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 | <template> <div id="baseLine"> <v-navigation-drawer v-model="drawer" app > <div class="navi-box"> <v-list mandatory> <v-list-item-group v-model="navi"> <v-list-item v-for="(item, i) in items" :key="i" :to="item.link" class="d-flex" > <v-list-item-icon> <v-icon v-text="item.icon" color="white"></v-icon> </v-list-item-icon> <v-list-item-content> <v-list-item-title v-text="item.text" class="font-weight-bold"></v-list-item-title> </v-list-item-content> </v-list-item> </v-list-item-group> </v-list> </div> </v-navigation-drawer> <v-app-bar app color="white" dark > <v-app-bar-nav-icon @click.stop="drawer = !drawer" /> <v-toolbar-title class="title font-weight-bold">Swallow Blog Ranking</v-toolbar-title> </v-app-bar> </div> </template> <script> export default { name: 'BaseLine', props: { source: String, }, data: () => ({ drawer: null, navi: null, item: 1, items: [ { text: '1ヶ月ランキング', icon: 'mdi-calendar', link: '/' }, { text: '3ヶ月ランキング', icon: 'mdi-calendar-multiple', link: '/three-months' } ] }), } </script> |
src/views/Home.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 | <template> <div id="home" class="pa-5" > <v-row> <v-col cols="4"> <v-card class="px-2 py-1" flat > <v-row class="align-center calendar" > <v-col cols="1" > <v-icon>mdi-calendar-blank</v-icon> </v-col> <v-col cols="11" > <vue-monthly-picker v-model="selectedMonth" :min="min" :max="currentMonth" :selectedBackgroundColor="bgColor" :clearOption=false dateFormat="YYYY-MM" ></vue-monthly-picker> </v-col> </v-row> </v-card> </v-col> </v-row> <template v-if="displayUrl.length != 0"> <v-row class="card-area" > <v-col cols="4" v-for="(items, index) in limitCount" :key="index" class="topCard d-flex" > <v-card flat > <p class="pv">{{displayUrl[index].pv}} <span class="pv-text">pv</span></p> <p class="date">{{displayUrl[index].date}}</p> <p class="title">{{displayUrl[index].title}}</p> <p class="writer">{{displayUrl[index].writer}}</p> </v-card> </v-col> </v-row> </template> <template> <v-data-table :headers="headers" :items="displayUrl" :items-per-page=30 no-data-text="データがありません" class="blog-ranking" > <tr v-for="(item, index) in displayUrl" :key="index" > <td>{{item.ranking[index]}}</td> <td>{{item.date[index]}}</td> <td>{{item.title[index]}}</td> <td>{{item.writer[index]}}</td> <td>{{item.pv[index]}} pv</td> </tr> </v-data-table> </template> </div> </template> <script> import VueMonthlyPicker from 'vue-monthly-picker' import moment from 'moment' import { mapActions } from 'vuex' export default { name: 'home', components: { VueMonthlyPicker }, data () { return { selectedMonth: '', currentMonth: moment().format('YYYY-MM'), startCurrentMonth: moment().format('YYYY-MM-01'), endCurrentMonth: moment().format('YYYY-MM-DD'), startCurrentSecond: moment().format('YYYY-MM-01T00:00:00'), endCurrentSecond: moment().format('YYYY-MM-DDTHH:mm:ss'), min: moment('2017/12'), lastMonth: moment().endOf('month'), perPage: 30, article: { items: [], url: [] }, display: { date: [], title: [], writer: [], pv: [], url: [] }, displayUrl: [], headers: [ {text: '順位', sortable: false, value: 'ranking'}, {text: '投稿日', sortable: false, value: 'date'}, {text: 'タイトル', sortable: false, value: 'title'}, {text: '投稿者', sortable: false, value: 'writer'}, {text: 'PV', sortable: true, value: 'pv'}, ], bgColor: '#1d4c65' } }, async created () { this.selectedMonth = moment() await this.getBlogDetail() await this.getData() await this.arrayToObject() }, watch: { selectedMonth: async function(newDate, oldDate) { if (!oldDate) return this.crearDate() if (newDate.format('YYYY-MM') == this.lastMonth.format('YYYY-MM')) {// 選択が今月だったら this.startCurrentMonth = this.selectedMonth.startOf('month').format('YYYY-MM-DD') this.endCurrentMonth = moment().format('YYYY-MM-DD') this.startCurrentSecond = this.selectedMonth.startOf('month').format('YYYY-MM-DDT00:00:00') this.endCurrentSecond = moment().format('YYYY-MM-DDTHH:mm:ss') } else {// 選択が今月以外だったら this.startCurrentMonth = this.selectedMonth.startOf('month').format('YYYY-MM-DD') this.endCurrentMonth = this.selectedMonth.endOf('month').format('YYYY-MM-DD') this.startCurrentSecond = this.selectedMonth.startOf('month').format('YYYY-MM-DDT00:00:00') this.endCurrentSecond = this.selectedMonth.endOf('month').format('YYYY-MM-DDT23:59:59') } await this.getBlogDetail() await this.getData() await this.arrayToObject() } }, methods: { ...mapActions({ getBlog: 'blog/getBlog', getGAnalytics: 'gAnalytics/getGAnalytics' }), crearDate () {//初期化 this.currentMonth = '', this.startCurrentMonth = '', this.endCurrentMonth = '', this.startCurrentSecond = '', this.endCurrentSecond = '', this.article.items = [], this.article.url = [], this.display.date = [], this.display.title = [], this.display.writer = [], this.display.pv = [], this.display.url = [], this.displayUrl = [] }, async getBlogDetail () {//wpから情報取得 const res = await this.getBlog({ params: { after: this.startCurrentSecond, before: this.endCurrentSecond, perPage: this.perPage } }) const data = res.data if (data) { this.article.items.push(data) } else { alert('WordPressからの情報取得に失敗しました。') } }, async getData () {//GA Reporting APIから情報取得 const res = await this.getGAnalytics({ params: { start: this.startCurrentMonth, end: this.endCurrentMonth, selectedMonth: this.selectedMonth.format('YYYYMM'), conditions: this.selectedMonth.format('YYYYMM') }, gapi: this.$gapi }) const data = res.data if (data) { for (let gaCount = 0; gaCount < data.length; gaCount++) { const gaUrl = data[gaCount].dimensions[1] //GAから取得したurl情報を順番に格納 const gaPv = data[gaCount].metrics[0].values[0] //GAから取得したpv情報を順番に格納 for (let wpCount = 0; wpCount < this.article.items[0].length; wpCount++) {//事前に取得したwpからの情報をfor文で回して順番に格納 const wp = this.article.items[0]//wpから取得した情報を一旦ここにまとめた const wpUrl = wp[wpCount].link.replace('https://swallow-incubate.com', '')//ドメインの文字列を削除 const wpDate = wp[wpCount].date.slice(0, -9)//投稿日(お尻の時間を削除) const wpTitle = wp[wpCount].title.rendered//ブログタイトル const wpWriter = wp[wpCount].blog_tag[0]//投稿者 if (gaUrl == wpUrl) {//GAのURLとwpのURLが同じだったら if(this.display.url.some(checkUrl => checkUrl == wpUrl)) {//一度格納したURLと同じものがまた回ってきたら for (let diff = 0; diff < this.display.url.length; diff++) { if(wpUrl == this.display.url[diff]) { const total = parseInt(this.display.pv[diff]) + parseInt(gaPv)//文字列を整数に変換 this.display.pv[diff] = total//元のPV数に後から回ってきたPV数を追加する } } break } else {//一度も格納したことのないURLならこっち this.display.date.push(wpDate)//投稿日を格納 this.display.title.push(wpTitle)//投稿者を格納 this.display.url.push(wpUrl)//URLを格納 this.display.pv.push(gaPv)//PV数を格納 let member = '' switch (wpWriter) { case ●●: member = 'メンバー1'; this.display.writer.push(member) break case ■■: member = 'メンバー2'; this.display.writer.push(member) break default: member = '不明' this.display.writer.push(member) break } } } } } } else { alert('GAからのデータ取得に失敗しました。') } }, async arrayToObject () {//PVでソートし直し for (let i = 0; i < this.display.url.length; i++) { this.displayUrl.push({ "url": this.display.url[i], "ranking": i+1, "date": this.display.date[i], "title": this.display.title[i], "writer": this.display.writer[i], "pv": parseInt(this.display.pv[i]) }) } this.displayUrl.sort((a, b) => { if (a.pv > b.pv) { return -1 } else { return 1 } }) for (let i = 0; i < this.display.url.length; i++) {//ランキングの数字を再計算 this.displayUrl[i].ranking = i + 1 } } }, computed: { limitCount() {//TOP3のみを表示 return this.displayUrl.slice(0, 3) } } } </script> |
src/store/index.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | import Vue from 'vue' import VueRouter from 'vue-router' Vue.use(VueRouter) const routes = [ { path: '/', name: 'home', component: () => import('../views/Home.vue') }, { path: '/three-months', name: 'threeMonths', component: () => import('../views/ThreeMonths.vue') } ] const router = new VueRouter({ mode: 'history', base: process.env.BASE_URL, routes }) export default router |
src/store/modules/blog.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 | import axios from 'axios' export const blog = { namespaced: true, state: { url: 'https://swallow-incubate.com/wp-json/wp/v2/blog?' }, actions: { async getBlog (state, obj) { const ApiURL = state.state.url + 'after=' + obj.params.after + '&before=' + obj.params.before + '&per_page=' + obj.params.perPage const result = await axios.get(ApiURL) .then(res => { if (res.status === 200) { return { result: true, data: res.data } } else { return { result: false, code: res.code, data: [] } } }) .catch(error => { if(error.message) { if (Array.isArray(error.message)) { let message = [] for (let err of error.message) { message.push(err.msg) } return { result: false, data: message.join(',') } } else { return { result: false, data: error.message } } } else { return { result: false, data: error } } }) return result } } } |
src/store/modules/gAnalytics.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 | export const gAnalytics = { namespaced: true, state: { url:'https://analyticsreporting.googleapis.com/v4/reports:batchGet', viewId: '[viewId]' }, actions: { async getGAnalytics ({state}, obj) { const requestParams = { reportRequests: [ { viewId: state.viewId, dateRanges: [ { startDate: obj.params.start, endDate: obj.params.end, } ], metrics: [ { expression: 'ga:pageviews' } ], dimensions: [ { name: 'ga:pageTitle' }, { name: 'ga:pagePath' } ], dimensionFilterClauses: [{ filters: [{ dimensionName: 'ga:pagePath', expressions: [ obj.params.selectedMonth ] }, { dimensionName: 'ga:pagePath', expressions: [ obj.params.conditions ] } ] }], orderBys: [{ fieldName: "ga:pageviews", sortOrder: "DESCENDING" }] } ] } const result = await obj.gapi.request({ path: state.url, method: 'POST', body: requestParams, headers: { 'content-Type': 'application/json' } }) .then(res => { if (res.status === 200) { return { result: true, data: res.result.reports[0].data.rows } } else { return { result: false, code: res.code, data: [] } } }) .catch(err => { console.log('gAnalytics', err) }) return result } } } |
完成イメージ
cssもちゃんと設定してあげて、こんな感じになりました。
これは、2月1日〜2月29日までに公開されたブログで、かつ2月1日〜2月29日までのPV数をカウントした1ヶ月のランキング画面になっております!
1ヶ月単位で見ると、そんな大した数字ではないのですが…3ヶ月ごとになると、トップ5くらいが爆発的な数字になってるんですよね。
1位は大体あの方がさらっと取っていくのですが、2&3位争いはいつも熾烈を極めているスワブロです。
コードの説明
さてさて、垂れ流しで紹介したコードですが…ざっくり説明しますね!
src/main.js
インストールしたvue google apiをimportして、作成したGoogle APIの情報を入れます。
clientId = OAuth2.0クライアントIDから作成したクライアントID
apiKey = APIキーから作成したキー
src/App.vue
外枠を作ってあげてます。
コンポーネンツにBaseLine.vueを作っているので、そちらの読み込みをしています。
src/components/BaseLine.vue
vuetifyのナビゲーションドロワーを使っています。
ここでは、1ヶ月と3ヶ月のランキングへのメニューを作っていますが、今回は1ヶ月ランキングのみ紹介します。
src/views/Home.vue
html部分ではtop3をvuetifyのcardでレイアウトしています。
テーブルはvuetifyのData tableを使用しています(手動ソートにも対応しているため、超便利!)
script部分は細かいものはコメントで説明しましたが、ざっくり説明するとこんな感じ。
- セレクトボックスに今月を代入
- WPからデータを取得
- GA Reporting APIからデータを取得
- 上記で集計して、ソートがおかしければ再集計
ちなみに、セレクトボックスは年月のみの選択となっていますが、script内ではこんな感じになっています。
〜今月(今日が2020年4月03日11時00分00秒の場合)〜
集計開始日:2020-04-01
集計終了日:2020-04-03
集計開始日(秒まで):2020-04-01T00:00:00
集計終了日(秒まで):2020-04-03T11:00:00
〜今月以外(2月を選択した場合)〜
集計開始日:2020-02-01
集計終了日:2020-02-29
集計開始日(秒まで):2020-02-01T00:00:00
集計終了日(秒まで):2020-02-29T23:59:59
今月以外だったら1ヶ月分をごっそり集計しちゃえばいいのですが、今月の場合は今日のなうタイムまでのデータしか作成されていないので…今月以外と同じ処理でデータをgetすると「んなデータねぇーよ!この野郎!」と怒られますww
そのためにwatchのif部分は細かく設定してあげてます。
あと268行でPVでソートし直しとありますが…
ここは、恐らくやらなくても大丈夫なんです。
ただ、弊社の場合ちょっと前にサーバー移管とかなんやらやって、数ページだけGAで取得している情報が同じURLなのに別れてる…という珍事象がありまして(原因がこれかは分かりませんが…
GA上では合体しているんですが、APIから取得してくると別れてるんです。
archives/blog/xxxxxxxx/:500pv
archives/blog/xxxxxxxx/:2pv
みたいな?
中村さんから月間発表されて「たくやが1位になったのに、こっちのアプリではたくや3位なんですけどwwwあれ?分裂してるwww」っていうね。
src/store/index.js
コードがゴチャゴチャしてくるため、modules/blog.js, modules/gAnalytics.jsというモジュールを作って管理しています。
src/store/modules/blog.js
Home.vueでまとめたparams(after, before, perPage)を受け取り、それをWP Rest APIに投げて、statusが200だったらdataに格納して、Home.vueに値をぶん投げてます。
参照:WP REST API
src/store/modules/gAnalytics.js
Home.vueでまとめたparams(start, end, selectedMonth, conditions)を受け取り、reportRequestsに条件を当てはめていきGA Reporting APIに投げて、statusが200だったらdataに格納して、Home.vueに値をぶん投げてます。
まとめ
いかがでしたでしょうか?
超ローングな内容だったので、大分はしょっちゃいました😋
一応私のローカルでは動いてるんですが…だ、大丈夫かな(ひやひや
次の問題は、社内サーバーにあげようとしたらGoogle APIが「IPアドレスじゃ受け付けませんぜ!」と弾くんで、そこで頭を抱えています。
中村さんに使ってもらえなかったら、作った意味がーーー😭
ちなみに、このアプリはGAで見る権限がないと見れないのも注意!
(多分誰でも見られる方法もある気はするが…とりあえず、社内で見られればでこんな感じにしています)
無事、スワローメンバーに見てもらえる日が来ることを祈って…このブログを締めたいと思います!
ではっ!
↓↓↓ぜひチェックしてください
~提供中のヒューマンセンシング技術~
◆人物検出技術
歩行者・来店者数計測やロボット搭載も
https://humandetect.pas-ta.io
◆視線検出技術
アイトラッキングや次世代UIに
https://eyetrack.pas-ta.io
◆生体判定技術
eKYC・顔認証のなりすまし対策を!
https://bio-check.pas-ta.io
◆目検出技術
あらゆる目周りデータを高精度に取得
https://pupil.pas-ta.io
◆音声感情認識技術
会話から怒りや喜びの感情を判定
https://feeling.pas-ta.io
◆虹彩認証技術
目の虹彩を利用した生体認証技術
https://iris.pas-ta.io