Vue.js初心者がWP REST APIとGA Reporting APIを使ってスワブロランキングアプリを作ったよ

2020.04.01

vue.js初心者がWP Rest APIとGoogle Analytics APIを使ってスワブロランキングアプリを作ったよ

 

ども、むったんです。

 

コロナ騒ぎで、ライブ&イベントが軒並み中止になってとても辛い。
えぇ、分かってます。
ウイルスを蔓延させないためって分かってます。
分かってる…けどさぁーーー!推しに会いたいよーーー😭

 

 

ちなみに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-monthly-picker

 

  • vue-google-api

Google ApiとGoogle認証を使用して、クライアント側の操作を行うのに必要なもの。

vue-google-api

 

ざっくり流れはこんな感じ

スワブロランキング流れ

 

WP REST APIは投稿順で情報をくれるっぽい。
GA Reporting APIは好きなようにソートできるっぽいので、PV昇順に指定。

 

for文で回して、GA Reporting APIから来た内容を基準にWP REST APIの情報もまとめて、ランキングを表示しました。

 

準備(WordPress)

WordPress4.7以上であれば、プラグインなどを使用せずに下記URLから記事情報等がjson形式で取得できます。

 http://[ドメイン]/wp-json/wp/v2/[slug] 

 

ちなみに、今回はスワブロの情報を取得したいので、こんな感じになります。

 https://swallow-incubate.com/wp-json/wp/v2/blog 

 

取得件数の初期値は10件です。

 

 

準備(Google API)

設定が面倒だったのはこっちだった…orz
そして未だちゃんと理解してないけど、動いてるから大丈夫なのかな😅

 

ざっくり説明すると、こんな感じ。

  1. Google API ConsoleからGoogle  Analytics Reporting APIを選択
  2. プロジェクトを作成
  3. 認証情報を作成
  4. Google Analyticsにサービスアカウントを追加
  5. APIキーを発行
  6. OAuth2.0クライアントIDを発行(むったんはhttp://localhost:8080を許可、状況に応じて変更してください)

 

詳しいやり方はSAKI Web Design様のブログに書いてありますので、参考にしてみてください🙏

Google Analytics Reporting API V4 を使う①|SAKI Web Design

コード

※コードが長くなるので、CSSは書きません。

src/main.js

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

<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

<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

<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

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

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

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部分は細かいものはコメントで説明しましたが、ざっくり説明するとこんな感じ。

  1. セレクトボックスに今月を代入
  2. WPからデータを取得
  3. GA Reporting APIからデータを取得
  4. 上記で集計して、ソートがおかしければ再集計

 

ちなみに、セレクトボックスは年月のみの選択となっていますが、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に値をぶん投げてます。

 

参照:リファレンスガイドQuery Explorer

 

まとめ

いかがでしたでしょうか?
超ローングな内容だったので、大分はしょっちゃいました😋
一応私のローカルでは動いてるんですが…だ、大丈夫かな(ひやひや

 

 

次の問題は、社内サーバーにあげようとしたらGoogle APIが「IPアドレスじゃ受け付けませんぜ!」と弾くんで、そこで頭を抱えています。
中村さんに使ってもらえなかったら、作った意味がーーー😭

 

ちなみに、このアプリはGAで見る権限がないと見れないのも注意!
(多分誰でも見られる方法もある気はするが…とりあえず、社内で見られればでこんな感じにしています)

 

 

無事、スワローメンバーに見てもらえる日が来ることを祈って…このブログを締めたいと思います!

 

 

 

ではっ!

 

 


Top