新着記事

Viewing posts for the category Javascript

サーバレスアプリケーション (HTML/JSのみ) で、Google Analytics API を使ってアクティブユーザー数を表示するダッシュボードを作る

Google Analytics は、API経由で様々な数値を取得することができます。
今回は、API経由でサイトのアクティブユーザー数を取得して、Nuxtで作ったダッシュボード風JSアプリに表示してみます。

Google Analytics の APIから値を表示してウェブページに表示する場合、APIとの通信をサーバサイドで PHP や Python のプログラムで取得してウェブブラウザに表示時する方法と、ウェブブラウザ自体が直接 Google Analytics API にリクエストして表示する方法と、どちらの方法でも実現できます。

サーバサイドで行う場合、認証情報やシステムを隠蔽でき、またHTTPリクエストが無くともバックグラウンドで値を取得し続けることができるなど、大きな利点がありますが、常時起動するサーバを用意しておかないといけないため構築が若干手間です。

今回は、手間をかけずに実現したかったため、ブラウザと静的リソースのみで動作するものを作ります。
APIへの認証は、ブラウザにログインしている Google ユーザーが行います。

認証のしくみ

Googleの提供している APIは、OAuth2 の認証・認可が必要となります。

アプリのページを開くと、Google のログインページを表示して認証を求め、認証されるとダッシュボードアプリの指定したコールバックURL にリダイレクトされるようにします。

リダイレクトされると、コールバックURL末尾の # (ハッシュ, フラグメント) の後にアクセストークンが含まれた状態になりますので、それを nuxt 内の JSでパースし、ブラウザのメモリ(変数)に格納します。

注意点として、認証情報(アクセストークン)がブラウザの変数に格納されるため、XSSが発生した際にアクセストークンが漏洩するリスクがあります。これはJSで認証情報を扱う以上避けられません。

XSSによる認証情報漏洩を防ぐには、GoogleとのAPIリクエストをサーバサイドで行い、サーバとの認証は httpOnly属性のついたセッションクッキーで行うべきですが、今回は、静的リソースなのでXSSは通常発生しないという前提の元、リスクを受容した上でクライアントサイドJSで機能を提供するものとします。

コールバックURLから取得したアクセストークンを認証ヘッダに含めて、ブラウザから Google Analytics にAPIリクエストをすることで、アクティブユーザー数などを取得できます。

アクセストークンは、localStorage に保存すると、セッション終了後も保持されるため漏洩の危険性が上がります。そのため変数として保存するだけにとどめます。ただし、OAuth2の state パラメータは、ブラウザセッションが変更されても維持する必要があるため、localStorage に一時的に保存します。

アプリのページを開くたびに、OAuth2のアクセストークンをリクエストし続けるとすると、毎回 Google のアカウント選択ページが出て面倒なように感じられるかもしれませんが、実際に使ってみるとそんなことはなく、一度ブラウザと Google の認証セッションクッキーが作られればあとはリダイレクトしか発生せず、1秒程度で自動的に認証が終わりアクセストークンが取得済みの状態になりますので、使用感として悪くはありません。

 試しにAPIにリクエストする

https://developers.google.com/analytics/devguides/reporting/realtime/v3/reference/data/realtime/get?#try-it

こちらの Google アナリティクスのページを見ると、APIの説明と共に、中央もしくは右側にAPIのテストリクエストができるフォームが表示されます。

ids に、 ga: を先頭につけた GoogleAnalytics のID番号を書き、metrics は rt:activeUsers を入れ、

下部 「Google OAuth 2.0」と「API key」にチェックを入れ、「Execute」 ボタンを押します。

すると、さらに下部にレスポンスデータが表示されます。
この情報を、nuxt 等ウェブアプリケーションで取得できます。

作り方

Google Cloud Platform のプロジェクトを作る

まず、Google APIを使うため、GCPのプロジェクトを作ります。

GCPのダッシュボードの上部ヘッダから、プロジェクトを新しく作ります。
https://console.cloud.google.com/home/dashboard

ライブラリの追加

プロジェクトを作ったら、APIとサービスページを開きます。
https://console.cloud.google.com/apis/dashboard

左メニューの「ライブラリ」をクリックし、ライブラリの追加ページで、Google Analytics API と Google Analytics Reporting API を有効にします。

認証情報の追加

次は、認証情報ページを開きます。
https://console.cloud.google.com/apis/credentials

上部「認証情報を作成」リンクから、「API キー」を選択し、APIキーを一つ作ります。

続いて、「認証情報を作成」リンクから、「OAuthクライアント ID」を選択します。

アプリケーションの種類 は、今回は「ウェブアプリケーション」で、名前は、適当に「nuxt client」とかにします。

「承認済みの JavaScript 生成元」は、クロスオリジンリクエストを許可するオリジン名です。
平文 http でもOKなので、

http://localhost:3000

などを追加しておきます。

「承認済みのリダイレクト URI」は、OAuth2 の認証フローのリダイレクトURL(コールバックURL)です。
認証完了時、このURLに、アクセストークンが URLに含まれた形でリダイレクトされます。
今回は、

http://localhost:3000/code

としました。
本番用のURLが決まっていたら、その分も追加します。後からでも追加できます。

スクリーンショットの例は、ローカルPCで docker + Nginx でポート80 でサービスする場合を考慮し、
http://localhost も追加してあります。

ちなみに、クライアントID とクライアントシークレット が作成されますが、ブラウザからのリクエストの場合はクライアントシークレットは使いません。

OAuth 同意画面は、今回は社内用途のみ考慮しているので「内部」を選択し、作成します。

プロジェクトの状態によっては、この選択肢は無いかもしれません。

これで、GCP のプロジェクトの設定は終了です。

アプリを書く

今回は、nuxt + TypeScript で書きます。すべてを載せると冗長なので、要点のみ記載します。

流れとしては、

1.
まずブラウザで / ページ表示時。
最初はアクセストークンが無いため、
store/auth.ts の navigateTokenRequestUrl がコールされる。

2.
navigateTokenRequestUrl の中では、ステートパラメータを作ってローカルストレージに保存し、
Google の認証URLに遷移。

3.
認証が完了すると、リダイレクトURL である /code に着地します。

4.
/code では、URLのハッシュ(フラグメント) から、アクセストークンとステートを取得し、
ステートの一致を検証後、アクセストークンを保存し、/ に遷移します。

5.
/ では、今回はアクセストークンがあるため、
コンポーネント内でそのアクセストークンを使って Google API でリアルタイムユーザー数を取得して表示します。

6.
このアクセストークンの寿命は1時間のため、1時間するとGoogle API は http 401 を返すようになります。
その場合、もう一度アクセストークン取得URLに遷移することで、アクセストークンの取り直しをします。

この時、Google とのセッションクッキーが認証済みであれば、ユーザー操作は不要で、一瞬画面がちらつくだけでアクセストークンの更新が完了します。

設定ファイル settings.ts

export const GOOGLE_API = {
authAPIEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
analyticsAPIEndpoint: 'https://www.googleapis.com/analytics/v3/data/realtime',
clientId: '<GoogleAPIの認証情報で作成されてたクライアントID>.apps.googleusercontent.com',
apyKey: 'AIz<GoogleAPIの認証情報で作成されてたAPIキー>o04',
scope: 'https://www.googleapis.com/auth/analytics.readonly'
}

export const GOOGLE_ANALYTICS_ACCOUNTS = [
{
id: '157xxxxx',
title: 'サイト1'
},
{
id: '872xxxxx',
title: 'サイト2'
},
]

型ファイル types/tasks.d.ts

interface TaskResult {
success: boolean;
message: string;
}

Vuex Store モジュール store/auth.ts

import { Action, Module, Mutation, VuexModule } from 'vuex-module-decorators'
import { GOOGLE_API } from '~/settings'

// OAuth2 state パラメータの localStorage一時保存用
const LOCAL_STORAGE_AUTH_STATE_KEY = 'authState'

@Module({
name: 'auth',
stateFactory: true,
namespaced: true
})
export default class extends VuexModule {
accessToken: string = ''

/**
* 認証済みか?
*/
get authorized () {
return this.accessToken !== ''
}

/**
* アクセストークンを保存
*/
@Mutation
setAccessToken (token: string) {
this.accessToken = token
}

/**
* OAuth2 認証URLに移動する
*/
@Action
navigateTokenRequestUrl () {
// 乱数で state パラメータを作成
const authState = [...Array(30)].map(
() => Math.random().toString(36)[2]).join('')
// state パラメータをローカルストレージに保存する
window.localStorage.setItem(LOCAL_STORAGE_AUTH_STATE_KEY, authState)
const params = [
['scope', GOOGLE_API.scope],
['include_granted_scopes', 'true'],
['response_type', 'token'],
['state', authState],
['redirect_uri', `${window.location.origin}/code`],
['client_id', GOOGLE_API.clientId]
]

window.location.href = `${GOOGLE_API.authAPIEndpoint}?` +
params.map(i => `${i[0]}=${encodeURIComponent(i[1])}`).join('&')
}

/**
* OAuth2認証完了後のURLのハッシュ(フラグメント)を解析して、アクセストークンを保存
*/
@Action
parseResponseParamsString (paramsString: string) : TaskResult {
// #key=value&key2=value2 形式の文字列を URLSearchParams にする
const usp = new URLSearchParams(
paramsString.replace(/^#/, '')) as any
// URLSearchParams を辞書型(マップ型)変数に変換
const paramsDict = [...usp.entries()].reduce(
(dict, e) => ({ ...dict, [e[0]]: e[1] }), {})
// localStorage に保存されている state と一致しているか検証
if (paramsDict.state !== window.localStorage.getItem(
LOCAL_STORAGE_AUTH_STATE_KEY)) {
return {
success: false,
message: 'stateが一致していません'
}
}
// state はもう使わないので消す
window.localStorage.removeItem(LOCAL_STORAGE_AUTH_STATE_KEY)
// アクセストークンがあるか検証
if (!paramsDict.access_token) {
return {
success: false,
message: 'アクセストークンが取得できません'
}
}
// アクセストークンがあったので変数に保存する。認証成功。
this.setAccessToken((paramsDict.access_token))
return {
success: true,
message: ''
}
}
}

pages/index.vue

ユーザーが最初に表示するページ

<template>
<div>
<header class="d-flex text-white p-2">
<div class="flex-grow-1 py-1">
Developer Dashboard
</div>
</header>
<div v-if="authorized">
<div class="container-fluid">
<div class="row">
<div
v-for="account in googleAnalyticsAccounts"
:key="account.id"
class="col-6 col-md-4 col-xl-3 my-3"
>
<RealtimePanel
:analytics-id="account.id"
:title="account.title"
/>
</div>
</div>
</div>
</div>
</div>
</template>

<script lang="ts">
import { Component, Vue } from 'nuxt-property-decorator'
import { authStore } from '~/store'
import RealtimePanel from '~/components/RealtimePanel.vue'
import { GOOGLE_ANALYTICS_ACCOUNTS } from '~/settings'
@Component({
components: {
RealtimePanel
}
})
export default class Index extends Vue {
get accessToken () {
return authStore.accessToken
}

get authorized () {
return authStore.authorized
}

get googleAnalyticsAccounts () {
return GOOGLE_ANALYTICS_ACCOUNTS
}

requestToken () {
authStore.navigateTokenRequestUrl()
}

mounted () {
// 認証済みでなければトークン取得URLへ遷移
if (!authStore.authorized) {
this.requestToken()
}
}
}
</script>

pages/code/index.vue

OAuth API 認証後のリダイレクトURL(コールバックURL)

<!--
OAuth2 認証後にリダイレクトされるURL
URLに含まれるハッシュ(フラグメント)から、アクセストークンを取得して変数に格納する。
-->
<template>
<div>
code received
</div>
</template>

<script lang="ts">
import { Component, Vue } from 'nuxt-property-decorator'
import { authStore } from '~/store'
@Component({
components: {
}
})
export default class Index extends Vue {
getRequestToken () {
authStore.navigateTokenRequestUrl()
}

async mounted () {
const taskResult = await authStore.parseResponseParamsString(
window.location.hash.replace(/^#/, ''))
if (!taskResult.success) {
throw new Error(taskResult.message)
}
this.$router.push('/')
}
}
</script>

components/RealtimePanel.vue

<!--
GAのアカウント1つに対応
一定時間ごとに、リアルタイムユーザー数を更新し続けるコンポーネント
-->
<template>
<div class="card">
<div class="card-header h2 py-3 text-truncate">
{{ title }}
</div>
<div class="card-body">
<div v-if="errorMessage" class="my-4">
{{ errorMessage }}
</div>
<div v-if="responseSuccess" class="text-center my-4">
<div class="display-1 fw-bold">
{{ activeUsers|addComma }}
</div>
<div class="text-muted small">
Active User
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop } from 'nuxt-property-decorator'
import { authStore } from '~/store'
import { GOOGLE_API } from '~/settings'

@Component({
components: {
}
})
export default class extends Vue {
activeUsers: number | null = 0
responseSuccess: boolean = false
errorMessage: string = ''

@Prop({
type: String,
required: true
})
title!: string

@Prop({
type: String,
required: true
})
analyticsId!: string

mounted () {
this.reloadPolling()
}

reloadPolling () {
this.reload()
setTimeout(() => {
this.reloadPolling()
}, 20000)
}

async reload () {
const response = await this.$axios.get(
GOOGLE_API.analyticsAPIEndpoint, {
params: {
key: GOOGLE_API.apyKey,
ids: `ga:${this.analyticsId}`,
metrics: 'rt:activeUsers'
},
headers: {
Accept: 'application/json',
Authorization: `Bearer ${authStore.accessToken}`
},
validateStatus: _ => true
}
)
// トークン期限切れ
if (response.status === 401) {
authStore.navigateTokenRequestUrl()
return
}
// 403: アクセス過多など
if (response.status !== 200) {
this.responseSuccess = false
this.errorMessage = `ERROR: ${response.status}`
return
}
if (response.data) {
this.activeUsers = parseInt(response.data.totalsForAllResults['rt:activeUsers'])
this.responseSuccess = true
this.errorMessage = ''
}
}
}
</script>

ブラウザ上で簡単なスクリプトをブックマークから動かす方法(ブックマークレット)

この記事は、非プログラマ向けの技術記事です。非プログラマが、プログラミングを始める足がかりとなることを目的としています。

WEBブラウザと日常業務

日常業務でウェブブラウザを使う機会はけっこうあると思います。いろいろなツールが Webアプリ化されるにつれ、ブラウザを業務で使う機会は昔と比べて増えました。

ただし、多くの項目があるフォームを毎日書く業務があったり、あるページを開いて項目を1つ1つスプレッドシートや他のWebアプリ、もしくはメールにコピペしたりといった業務があったりもします。もし、その業務が面倒だと感じているならば、自動化できる余地は十分あります。

プログラマは、面倒だと思った作業はすぐに自動化できますが、プログラミングに明るくない方でも、範囲は限定的ですが Webアプリであれば面倒が改善できるかもしれません。

WEBブラウザでプログラムを動かせる

お使いのブラウザで、今すぐに自分でプログラムを書いて実行できます。そういう機能がブラウザに入っています。

Windows の Chrome, Firefox であれば、Ctrl + Shift + i を押してみてください。Edge をお使いであれば F12 です。Mac であれば Command + Option + i です。

すると、画面が分割され別のペインが出てきます。もし、警告文が出てきたら読み、同意/認可ができるようであればブラウザの案内にそって対応してください。

おそらく最初は、Firefoxの場合は「インスペクタ」、Chromeの場合は「Elements」タブが有効になっていると思います。その隣に、「コンソール」「Console」タブがありますので、それをクリックするとコンソールモードになります。

↑ Firefox

↑ Google Chrome

Firefox では、一番下に >> となっています。ここをクリックするとプログラムが入力できます。Chrome の場合は一番上に > があり、ここにプログラムが入力できます。

試しに、簡単な計算式を書くと、計算ができます。

↑消費税計算と81の平方根の算出をした例

Math.sqrt は、ブラウザの Javascript エンジンに最初から組み込まれている平方根計算の関数です。どんなことができるかはJavascript の入門書を読むと良いと思いますが、「何かをウェブページに表示する」であれば大概できます。(ただし、表示しているサイトとは別のサイトから持ってきた情報を表示するのは少し難しい場合があります)

別の例を試します。コンソールに alert('今日は' + new Date()) と入力します。そうすると、現在の日時がポップアップで表示されます。

また、history.back() を入力すれば、ブラウザの「戻る」ボタンを押した時と同じように、前のページへ戻ることができます。

このように、ウェブブラウザには非常に簡単にプログラムを動かす仕組みが入っています。

アドレスバーからでもプログラムを実行できる

先程は開発コンソールからプログラムを実行しましたが、ブラウザ上部にあるアドレスバーからでもプログラムの実行ができます。

…できたはずなんですが、最近のブラウザは、アドレスバーに入力した文字列が URL ではないと判断した場合、検索エンジンで検索するようになっているので、アドレスバーにスクリプトを入力しての実行する方法は最近のブラウザでは難しくなっています。

昔のブラウザでは、アドレスバーに

javascript:alert('今日は' + new Date());void(0);

と入力すると、先程コンソールでプログラムを動かしたのと同様に alert を動作させることができました。

アドレスバーに直接入力しての動作はできなくなりましたが、リンクのタグとして Webページに記録すれば動作させることができます。

プログラムを動作させるリンクタグ(クリックで上記プログラムを実行)

ブックマークからでもプログラムを実行できる

アドレスバーに入力したプログラムが動作するということは、そのプログラムをブックマークとして保存しておけば、そのブックマークを呼び出した時にプログラムが動作することになります。

試しに、上記「プログラムを動作させるリンクタグ」をブックマークしてください。リンクをブックマークツールバーにドラッグアンドドロップすると良いでしょう。

ブラウザによっては、「ブックマークの新規作成」のようなメニューから、さきほどの

javascript:alert('今日は' + new Date());void(0);

このスクリプトをURLとして登録すればブックマークできます。

ブックマークができたら、クリックしてください。同様にプログラムが動いて、ポップアップでメッセージが表示されたと思います。

このように、ブックマークの中にプログラム(Javascript)を記録して、クリックして実行させる方法を「ブックマークレット」といいます。

ブックマークレットのサンプル

試しに作ってみました。

ページの表示を崩すブックマークレット

ページの表示を崩す

これをブックマークして、好きなページで実行することでページの表示を崩すことができます。

元に戻すにはページをリロードしてください。

スクリプトの内容を書いておきます

var divs = document.getElementsByTagName('div');
var mode = Math.floor(Math.random() * 2);
for (var i = 0; i < divs.length; i++){
  var delay = Math.random() * 1000;
  var d = divs[i];
  setTimeout(function(d) {
   if (d){
    var t = Math.floor(Math.random() * 2000 + 1000);
    d.style.transition = "all " + t +"ms ease-in";
    var deg = Math.random() * 30 - 15;
    d.style.transform = "translate(0, 800px) rotate(" + deg + "deg)";
  }
  }, delay, d);
}

フォームの全 Input の value に name を入れるブックマークレット

inputのvalueにnameを入れる

function d(inputs){
  for(var i =0; i<inputs.length; i++){
    inputs[i].value =inputs[i].name;
  }
}
d(document.getElementsByTagName("input"));
d(document.getElementsByTagName("textarea"));

「フォーム内容を復元するブックマークレット」を作るブックマークレット

フォーム内容を保存

実行すると、ページ上にリンクが1つできます。そのリンクがブックマークレットになっているので、ブックマークしてください。

次回、同じフォームを表示した時にそのブックマークレットを実行することで内容を再度入力することができます。

var out = "javascript:function sC(n,v,c){var elms=document.getElementsByTagName('input');for(ind in elms){o=elms[ind];if(o.name==n && o.value==v){o.checked=c;}}}function sI(n,v,c){var elms=document.getElementsByTagName('input');for(ind in elms){o=elms[ind];if(o.name==n){o.value=v;o.checked=c;}}}function sO(v){var elms=document.getElementsByTagName('option');for(ind in elms){o=elms[ind];if(o.value==v){o.selected='selected'}}}function sT(n,v){var elms=document.getElementsByTagName('textarea');for(ind in elms){o=elms[ind];if(o.name==n){o.value=v;}}}";

var elms, ind, o;
elms = document.getElementsByTagName('input');
for (ind in elms) {
    o = elms[ind];
    if (o.type == "hidden") {
        continue;
    } else if ((o.type == "checkbox" || o.type == "radio") && o.name && o.value) {
        out += "sC('" + o.name + "','" + encodeURIComponent(o.value) + "'," + o.checked + ");";
    } else if (o.name && o.value) {
        out += "sI('" + o.name + "','" + encodeURIComponent(o.value) + "');"
    }
}
elms = document.getElementsByTagName('option');
for (ind in elms) {
    o = elms[ind];
    if (o.selected) {
        out += "sO('" + o.value + "');"
    }
}
elms = document.getElementsByTagName('textarea');
for (ind in elms) {
    o = elms[ind];
    if (o.value) {
        out += "sT('" + o.name + "','" + encodeURIComponent(o.value.replace(/\s+/g, ' ')) + "');"
    }
}
out += "void(0);";
console.log(out);
var a = document.createElement('a');
a.href = out;
a.textContent = 'フォーム自動入力';
a.style.display = 'block';
a.style.position = 'fixed';
a.style.backgroundColor = '#eee';
a.style.padding = "40px";
a.style.top = "100px";
a.style.left = "100px";
a.style.zIndex = 1000;
a.style.border = "1px solid black";
document.body.appendChild(a);

ブックマークレットでできること

表示しているページの改変や操作が得意です。

ブラウザで表示しているページは HTML で出来ていて、ブックマークレットではその HTML のかなり多くの箇所を操作できるため、「表示しているページに対して何かを行う」ことに関しては得意です。

例えば、入力フォームが表示されている時、その入力フォームに対して何かを行う(自動入力など)ことについては他にないほど便利です。

複数ページにまたがった処理や、別のサイトのページに対しての処理は苦手です。

ページの遷移を行うと処理が止まってしまうため、複数ページの処理はあまり得意ではありません。特に、複数のサイトを同時に操る操作はブラウザの制約があるため難しいです。がんばればなんとかなりますが、他の手段を用いたほうが良いでしょう。Selenium とか Windows Scripting Host とか。

Javascript をブックマークレットに変換する方法

ページに対して何かを行う Javascript が書けた場合(思い浮かんだ場合)、それをブックマークレットの形式にするには少しの変換が必要です。

  • 改行や余計なスペースを除去する
  • 記号を URL エンコードする
  • 先頭に「javascript:」をつける
  • 最後に void(0) をつける、もしくは全体を void() で囲む、など

(一番最後のは、処理が undefined で評価されないとページ遷移が発生して、真っ白いページが表示されるなど変な結果になってしまうため入れてます)。

短いスクリプトであれば手で修正できますが、長いスクリプト、特にインデントが入っているものなんかは手で変換するのは大変です。

変換スクリプトを書いて github pages で上げておきましたので、よかったら使ってください。

Bookmarklet スクリプト変換

Search

Recent Tweets

  • ytyng

    ytyng @ytyng

    @linear_pulse 同様の現象が出てました。
    4 days, 16 hours ago

  • ytyng

    ytyng @ytyng

    django-allauth 0.45 の 昨日のリリースで Python3系からの Apple Signin できない不具合直ってる https://t.co/JHcM3g2GCT
    1 week, 4 days ago

  • ytyng

    ytyng @ytyng

    あんまりPC詳しくない人とか普通に踏んじゃうと思うので、キヤノン製品は控えることにする
    1 month, 2 weeks ago

  • ytyng

    ytyng @ytyng

    ダメサイトっぽい https://t.co/GBlGq9T3Oe 公式はこちらでした https://t.co/sPvhEowLdt
    1 month, 2 weeks ago

  • ytyng

    ytyng @ytyng

    whoisも怪しすぎるんだけど
    1 month, 2 weeks ago