見出し画像

Twitter広告のコンバージョンAPIをサーバーサイドGTMで実装する方法を解説 - 後編

前回の「TwitterのコンバージョンAPIをサーバーサイドGTMで実装する - 前編」に続きになります。

ssGTMで実装すると言いつつ、前回はssGTMは全く出てこなかったわけですが、いよいよ実装していきたいと思います。

前回も述べましたが、ssGTMだけで完結させるのは難易度が高く、、
前編の記事を読んでいただいたら分かると思うのですが、
HMAC-SHA1の部分がしんどいです。

そのため、
ssGTM 85%、Python(+FastAPI)15%で実装
ssGTM 40%、Google Apps Script(GAS) 60%で 実装
という感じで他のツールの助けも借りながら進めていければと思います。

※前編からの続きなので、見出しの番号も「Ⅵ」からスタートします。

Ⅵ. なぜssGTMを使うのか

前編でpayloadの部分が気になった人もいると思います。

例えばGASなら以下のように書いていましたよね。

const payload = {
    conversions:[
        {
            conversion_time: conversion_time,
            event_id: "tw-hogehoge-fugafuga",
            identifiers: [
                {
                    hashed_email: "48ddb93f0b30c475423fe177832912c5bcdce3cc72872f8051627967ef278e08"
                }
            ],
            value: "1000",
            price_currency: "JPY",
            number_item: 2,
            transaction_id: "abc2222",
            contents: [
                {
                    content_id: "ab34",
                    content_name: "T-Shirts",
                    content_type: "Apparel",
                    content_price: "500",
                    num_items: 2
                }
            ]
        }
    ]
}

contentsの中身とか固定して書いてますよね?
でも普通に考えてコンバージョンの度に変わりますよね。

毎回、valueが「1000」で、conent_idが「ab34」なわけはないですよね。
なので、ここはコンバージョンの度に動的に変化しないといけない

もちろん、サーバー側でトランザクションを管理していれば、その情報を渡せばいい話です。
通常はそのように実装して、コンバージョンAPIを設定すると思います。

いわゆる、Meta社の Facebook コンバージョンAPI における『直接実装』のようなイメージですね。

ですが、広告運用者やマーケターの方々がそこまでガッツリサーバーの話に入り込むのは大変なので、ssGTMの力を借りましょう、という感じです。

(あるいは、すでに他の媒体・・・上記のMetaのFacebook コンバージョンAPIや、Google のコンバージョンタグでssGTMを使っているよ!という場合は、「せっかくならTwitterもssGTMで。。。」となるのも自然な流れではないかなと思います。)

というわけで、このpayloadの部分をssGTMで構築してあげて、
HMAC-SHA1認証の部分(oauth_signatureを生成する部分)は、
他のツールでやってあげましょう、というのが今回の狙いです。

その中でも
ssGTM 85%、Python(+FastAPI)15%で実装
については、なるべくssGTMで完結させつつ、
本当に一部のところをPythonにやってもらおう、という話

ssGTM 40%、Google Apps Script(GAS) 60%で 実装
については、上記のとおり、payloadの部分だけssGTMで構築して、あとはGASにやってもらおう、という話

になっています。

なお、ssGTMの導入方法については触れていません。
そちらについてはFacebookのコンバージョンAPIを導入する記事で
かなり細かく記載しているので、参照ください。

Ⅶ. ssGTM + Python実装

まずは、ssGTMでテンプレートへ行きましょう。

そこから、タグテンプレートを制作していきます。
「新規」をクリック

名前はなんでもOK!

「項目」のタブを開きましょう。
本当はコードを書きながら、「この項目も必要だな、、」みたいな感じで追記していきますが、今回は先に見せます。

一旦、以下を書いてみましょう。
項目名と表示名は一緒で大丈夫です。

こちらで記載したものが、タグを作るときの設定項目として出てきます。
前編でも記載しましたが、「twclid か hashed_email」少なくとも1つは必須ですので、今回はテストでhashed_emailを渡すようにしています。

twclidを渡すのもそんなに難しくないですので、twclidの項目を作成するのも全然ありです!

hashed_email(やtwclid)はここで変数として渡すことをしなくても、
クライアントGTMから送られた情報をEvent Dataとして受け取り、そのままカスタムテンプレート内で使えます。
今はテストなので↑↑項目としてhashed_email入れていますが、本番ではEvent Dataから取得して使えばOKです。(Event Dataについては後述しています)

なお、今回、「ssGTM + Python」ではなるべくssGTMで完結させ、
Pythonではあまり細かいことやらない予定なので API_KEYとかもここで設定しますが、本当はPythonをデプロイしてるサーバー側の環境変数とかに入れておいた方が余計な通信がなくていいと思います。

本命はGASの方なので、Pythonはお遊びとしてご覧ください。
※もちろん、GASで書いていることをPythonで書くことも可能です。

ssGTMの記述

さて、まずはssGTM側のコード記述を見ていきましょう。
コードのタブに移動してください。

先にコードを載せます。
APIについては公式も参照ください。

// テンプレート コードをここに入力します。
const getAllEventData = require('getAllEventData');
const sendHttpRequest = require('sendHttpRequest');
const JSON = require('JSON');
const Math = require('Math');
const getTimestampMillis = require('getTimestampMillis');
const encodeUriComponent = require('encodeUriComponent');
const getCookieValues = require('getCookieValues');
const Object = require('Object');
const Promise = require('Promise');
const toString = require('makeString');
const logToConsole = require('logToConsole');


const twJson = {
    API_KEY: data.API_KEY,
    API_KEY_SECRET: data.API_KEY_SECRET,
    ACCESS_TOKEN: data.ACCESS_TOKEN,
    ACCESS_TOKEN_SECRET: data.ACCESS_TOKEN_SECRET
};


const capiUrl= "https://ads-api.twitter.com/11/measurement/conversions/" + data.pixel_id;

function getBaseUrl(url){
  return url.split('?')[0];
}


function percentEncode(str){
  return encodeUriComponent(str)
    .split("!").join('%21')
    .split("*").join("%2A")
    .split("'").join("%27")
    .split("(").join("%28")
    .split(")").join("%29");
}

function getContentFromItems(items){
  return items.map((item) => {
      return {
          "content_id": item.item_id,
          "content_name": item.item_name,
          "content_type": item.item_category,
          "content_price": toString(item.price),
          "num_items": item.quantity,
          "content_group_id": item.item_list_id      
      };
  });
}


const eventModel = getAllEventData();

const payload = {};
payload.conversions = [];

const cvdata = {};
cvdata.conversion_time = eventModel.isotime;
cvdata.event_id = data.event_id;

cvdata.identifiers = [];
if (eventModel['x-tw-twclid']) {
  cvdata.identifiers.push({twcid: eventModel['x-tw-twclid']});
}

const hashEmail = eventModel['x-tw-hashed_email'] || data.hashed_email;
if(hashEmail){
  cvdata.identifiers.push({hashed_email: hashEmail});
}


cvdata.value = toString(eventModel.value);
cvdata.price_currency = eventModel.currency;
cvdata.number_item = eventModel['x-tw-cd-num_items'];
cvdata.conversion_id = eventModel.transaction_id;
cvdata.contents = eventModel['x-tw-cd-contents'] || (eventModel.items != null ? getContentFromItems(eventModel.items): null);

payload.conversions.push(cvdata);

let oauthParams;
let signingKey;
let basestring;
let text="";

const resNonce = sendHttpRequest("https://hogehoge.deta.dev/nonce",{
    headers: {'Content-Type': 'application/json'},
    method: 'POST'
}).then((val) => {  
   oauthParams = {
    oauth_consumer_key: twJson.API_KEY,
    oauth_nonce: JSON.parse(val.body).nonce,
    oauth_signature: "",
    oauth_signature_method: "HMAC-SHA1",
    oauth_timestamp: Math.floor(getTimestampMillis()/1000),
    oauth_token: twJson.ACCESS_TOKEN,
    oauth_version: "1.0"
};
  
  for(let k in oauthParams){
    if(k === "oauth_signature"){
        continue;
    }
  text += percentEncode(k) + "%3D" + percentEncode(oauthParams[k]) + "%26";
}
  text = text.slice(0, -3);
  signingKey = percentEncode(twJson.API_KEY_SECRET) + "&" + percentEncode(twJson.ACCESS_TOKEN_SECRET);
  basestring = "POST&" + percentEncode(getBaseUrl(capiUrl)) + "&" + text;
});

Promise.all([resNonce]).then(()=>{
  const sigBody = {
      key: signingKey,
      value: basestring
};
  
sendHttpRequest("https://hogehoge.deta.dev/sig", {
    headers: {'Content-Type': 'application/json'},
    method: 'POST'
}, JSON.stringify(sigBody)).then((res) => {
    let oauth_signature = JSON.parse(res.body).signature;
    oauthParams.oauth_signature = oauth_signature;    
  
    const headerParams = Object.keys(oauthParams).map((key) => {
    return key + "=" + oauthParams[key];
  });  
    return headerParams;
}).then((result) => {
  const headers = {
      'Authorization': 'OAuth ' + result.join(','),
      'Content-Type': 'application/json'
  };
  
  sendHttpRequest(capiUrl, {
    headers: headers,
    method: 'POST'
  },JSON.stringify(payload)).then((res) => {
    logToConsole(res);
    if(res.statusCode === 200){
    data.gtmOnSuccess();
    } else {
    data.gtmOnFailure();
    }
  }).catch((e) => {data.gtmOnFailure();});
 
});  
});

今回、「getCookieValues()」は使っていませんが、
twclidをCookieから取得する場合などには活用できると思います。

上の記述ではtwclidはクライアント側のGTMから情報をもらう形にしています。getCookieValues()を使ってサーバー側で取得することも可能なわけですね。(その場合は、GCPでカスタムドメインの設定をして、ドメインを自社のものにしておく必要があります。)

リクエストbodyのpayloadを作る部分と、ヘッダーを作る部分がメインになっています。

payloadについては空のオブジェクトや配列を用意して、そこに追加していくように記述しています。そのため、全体感が分かりにくいかもしれませんが、全体としては前編で書かせていただいたような、下記のイメージになります。

const payload = {
    conversions:[
        {
            conversion_time: conversion_time,
            event_id: "tw-hogehoge-fugafuga",
            identifiers: [
                {
                    hashed_email: "48ddb93f0b30c475423fe177832912c5bcdce3cc72872f8051627967ef278e08"
                }
            ],
            value: '2000',
            price_currency: 'JPY',
            number_item: 2,
            transaction_id: 'abc123',
            contents: [
              {
                content_id: '12345',
                content_name: 'shoes',
                content_type: 'Apparel',
                content_price: '1000',
                num_items: 2
              }
            ]
        }
    ]
};

↓↓このあたり↓↓の記述は、全て上記のpayloadを作成していると思ってください。

const eventModel = getAllEventData();

const payload = {};
payload.conversions = [];

const cvdata = {};
cvdata.conversion_time = eventModel.isotime;
cvdata.event_id = data.event_id;

cvdata.identifiers = [];
if (eventModel['x-tw-twclid']) {
  cvdata.identifiers.push({twclid: eventModel['x-tw-twclid']});
}

const hashEmail = eventModel['x-tw-hashed_email'] || data.hashed_email;
if(hashEmail){
  cvdata.identifiers.push({hashed_email: hashEmail});
}


cvdata.value = toString(eventModel.value);
cvdata.price_currency = eventModel.currency;
cvdata.number_item = eventModel['x-tw-cd-num_items'];
cvdata.conversion_id = eventModel.transaction_id;
cvdata.contents = eventModel['x-tw-cd-contents'] || (eventModel.items != null ? getContentFromItems(eventModel.items): null);

payload.conversions.push(cvdata);

payloadについてはもう少し説明しておきます。

前提として『クライアントサイドGTMからサーバーサイドGTMへデータを送る方法』を理解していないと難しいかもしれません。

これについは何度も手前味噌で申し訳ないですが、FacebookのコンバージョンAPIの記事で細かく書いているので、詳しくはそちらを参考ください。
(主に「6章」あたり)

ここでは簡単にお話します。

クライアントサイドGTMの『GA4 イベントタグ』において、
パラメータと値を設定することで、そのパラメータと値のセットをサーバーサイドGTMを送ることができます。
(※GCPの設定や、その他GA4設定タグなどの準備が出来ている前提で)

例えば以下のような感じです。(値の変数名とかは適当なのであまり気にしないでください)

ここでssGTMに送ったものをどこで確認できるかというと、
簡単なのはssGTMのプレビューモードで「Event Data」を見ることです。

実際に、上記の『パラメータ名』と、そのパラメータに入っている『値』が確認できます。

コードに戻ります。

const eventModel = getAllEventData();

これは、ssGTMのEventDataをすべて取得してね、ってことですが
つまり、先のプレビューモードの「Event Data」に現れているパラメータと値を「eventModel」という変数に格納しています。

そして、例えば『eventModel.isotime』とかけば
「Event Data」のisotimeで取れてきている値を返してくれます。
上のキャプチャでいえば『"2022-09-22T01:26:17.474Z"』という文字列を返していまず。

他にも、「eventModel.event_id」や「eventModel['x-tw-twclid']」、「eventModel['x-tw-cd-contents']」などもありますが、同様に、クライアントサイドGTMから送っている「event_id」や「'x-tw-twclid'」、「'x-tw-cd-contents'」を取得して、ssGTMで使えるようにしています。(「'x-tw-cd-contents'」についてはまた後述します。)

※twclidはssGTM側でゲットすることも可能ですが、Cookieから取得するにはGCPでカスタムドメインの設定をしている必要があります。(URLから取る場合はその限りではないです)。
※GCPのカスタムドメイン設定にすいても上記Facebook コンバージョンAPI記事の「5章」に記載しています。

※少し前にも記載していますが、「hashed_email」も実際はEvent Dataで受け取るパターンが多いかと思います。コードの記述でいうと

const hashEmail = eventModel['x-tw-hashed_email'] || data.hashed_email;

の部分になります。パラメータ名を「'x-tw-hashed_email'」としてクライアントGTMから受け取る想定です。

今回はテストなので「hashed_email」の項目をssGTMに追加しています。
(項目に入力された値は「data.項目のフィールド名」で使えます。)

また、パーセントエンコードは以下の関数で定義しています。

function percentEncode(str){
  return encodeUriComponent(str)
    .split("!").join('%21')
    .split("*").join("%2A")
    .split("'").join("%27")
    .split("(").join("%28")
    .split(")").join("%29");
}

GASと同様に、基本的には「encodeUriComponent」という関数を使用しています。エンコード出来ていない一部の文字(「!」「*」「'」「(」「)」)は手動でリプレイスしています。

GASの「replace」メソッドには、正規表現の「gオプション(当てはまる全ての文字を置換する)」があるのですが、ssGTMではそれができないようです。

ssGTMでもreplaceメソッド自体は使えるのですが、gオプションが使えないため、「最初に当てはまった1文字だけ」置換されます。

そのため、splitとjoinを組み合わせて置換しています。

また、payloadの「contents」部分について、、
こちらは、クライアント側のGTMから明示的に送ることも可能ですし、
あるいは、GA4のec設定(itemsの設定)をしていれば、Twitter仕様に変換する関数を記載しています。(↓↓以下のコード)

(クライアント側から明示的に送る場合は、前述の「'x-tw-cd-contents'」のパラメータに商品情報の配列を渡すことになります。)

function getContentFromItems(items){
  return items.map((item) => {
      return {
          "content_id": item.item_id,
          "content_name": item.item_name,
          "content_type": item.item_category,
          "content_price": toString(item.price),
          "num_items": item.quantity,
          "content_group_id": item.item_list_id      
      };
  });
}

なお、↑↑をご覧いただければ分かるかもですが、「content_price」は文字列に変換しています(toString)。同様に「value」も文字列にしています。(なぜ文字列にするかは前編も参考にしてください)

↓↓こちらは、Pythonで構築したAPIにリクエストを投げて、oauth_nonceをゲットして、それを使ってHMAC計算に使う「signingKey」や「baseString」を作成しています。

const resNonce = sendHttpRequest("https://hogehoge.deta.dev/nonce",{
    headers: {'Content-Type': 'application/json'},
    method: 'POST'
}).then((val) => {  
   oauthParams = {
    oauth_consumer_key: twJson.API_KEY,
    oauth_nonce: JSON.parse(val.body).nonce,
    oauth_signature: "",
    oauth_signature_method: "HMAC-SHA1",
    oauth_timestamp: Math.floor(getTimestampMillis()/1000),
    oauth_token: twJson.ACCESS_TOKEN,
    oauth_version: "1.0"
};
  
  for(let k in oauthParams){
    if(k === "oauth_signature"){
        continue;
    }
  text += percentEncode(k) + "%3D" + percentEncode(oauthParams[k]) + "%26";
}
  text = text.slice(0, -3);
  signingKey = percentEncode(twJson.API_KEY_SECRET) + "&" + percentEncode(twJson.ACCESS_TOKEN_SECRET);
  basestring = "POST&" + percentEncode(getBaseUrl(capiUrl)) + "&" + text;
});

sendHttpRequestはリクエストを投げて、結果をPromiseのインスタンスで返してくれます。なので、thenで繋げて色々処理することが可能です。

ここで「oauthParams」を作ったり、signingKeyやbaseStringを作成する過程はGASのときとほぼ同じですので、前編のGASの章もご覧ください。

そして、以下で、Pythonで作成したAPIに「signingKey」と「baseString」を投げて、Python側でHMAC-SHA1の認証計算してもらっています。

そして、結果として「oauth_signature」を受け取っています。
この「oauth_signature」を「oauthParams」にマージして、そこから Authorizationのヘッダーを作成しているわけです。

最終的に、TwitterのコンバージョンAPIのエンドポイントにリクエストを送信しています。

Promise.all([resNonce]).then(()=>{
  const sigBody = {
      key: signingKey,
      value: basestring
};
  
sendHttpRequest("https://hogehoge.deta.dev/sig", {
    headers: {'Content-Type': 'application/json'},
    method: 'POST'
}, JSON.stringify(sigBody)).then((res) => {
    let oauth_signature = JSON.parse(res.body).signature;
    oauthParams.oauth_signature = oauth_signature;    
  
    const headerParams = Object.keys(oauthParams).map((key) => {
    return key + "=" + oauthParams[key];
  });  
    return headerParams;
}).then((result) => {
  const headers = {
      'Authorization': 'OAuth ' + result.join(','),
      'Content-Type': 'application/json'
  };
  
  sendHttpRequest(capiUrl, {
    headers: headers,
    method: 'POST'
  },JSON.stringify(payload)).then((res) => {
    logToConsole(res);
    data.gtmOnSuccess();
  }).catch((e) => {data.gtmOnFailure();});
 
});  

「Promise.all」は通常のJavaScriptの「Promise.all」と同じ役割です。
少し前にnonceを作るためにPythonにリクエスト投げていますが、その返り値であるPromiseインスタンスをresNonceという変数に受けています。

Promise.all([resNonce])とすることで、resNonceのレスポンスを受け取ってから処理を進めることが可能です。

要するに、『nonce計算のレスポンスが返ってくるのを待ってから、次の処理に進みますよ。』という感じに思ってください。

で、signingKeyとbaseStringを投げているのが↓↓こちらです。

sendHttpRequest("https://hogehoge.deta.dev/sig", {
    headers: {'Content-Type': 'application/json'},
    method: 'POST'
}, JSON.stringify(sigBody)).then((res) => 

ここでも「oauth_signature」が返ってくるのを待って、「then」メソッドで次の処理へ行っています。

つまり、この段階で「oauth_nonce」も「oauth_signature」も揃っている状況ですので、もうヘッダー作成に必要な情報は手元にあります。

あとはGASのときと同じような処理をして、Authorizationのヘッダーを作り、最後にTwitter コンバージョンAPIのエンドポイントへリクエストを送信しています。

※余談
コードの下部のこの部分。正直、「then」で繋げなくても、問題なくコード動きます。ただ、すでに「then」の中に「then」がネストされていて、さらにネストすることになるので(なんとなく気持ち悪いので)、一旦ここで区切って、新しく「then」で繋げています。

    return headerParams;
}).then((result) => {
  const headers = {
      'Authorization': 'OAuth ' + result.join(','),
      'Content-Type': 'application/json'
  };

コードの話はこのあたりにしましょう。

あと設定が必要なのはssGTMの「権限」タブになります。

こちらを適切に設定するのがおすすめです。
HTTPリクエスト以外は任意でも大丈夫かとは思いますが、
少なくともHTTPリクエストの送信については設定しましょう。

今回でいうと、「Pythonコードをデプロイしているサーバー」と「TwitterコンバージョンAPIのエンドポイント」のドメインですね。

以下のような感じです。

もちろん、ドメインじゃなくて、パスまで指定することが可能なので、より厳密にしていしたい場合は設定しましょう。

では、ssGTMはこの辺にしておき、Pythonの方に参りましょう。

Pythonの記述

Python側の記述を見ていきます。

今回、Pythonでやってもらいたいことの本命は『oauth_signature』の計算です。

なのですが、ついでに『oauth_nonce』の算出もしてもらおうと思います。

前編でも見たように、『oauth_nonce』はタイムスタンプでも問題ないのですが、Twitterの推奨通り、『32バイトのランダムな文字列をbase64でエンコードして記号類を除く方法』を採用してみます。となると、Pythonで書く方が簡単そうなので。

The oauth_nonce parameter is a unique token your application should generate for each unique request. Twitter will use this value to determine whether a request has been submitted multiple times. The value for this request was generated by base64 encoding 32 bytes of random data, and stripping out all non-word characters, but any approach which produces a relatively random alphanumeric string should be OK here.

https://developer.twitter.com/en/docs/authentication/oauth-1-0a/authorizing-a-request

FastAPIを使って、APIを自作して「特定のURLにPOSTメソッドでアクセス」があった場合に、oauth_nonceの値を返したり、oauth_signatureの値を返すようにします。

コードとしては以下のような感じです。

import base64
import hmac
import hashlib
import urllib.parse
import secrets

from fastapi import FastAPI
from pydantic import BaseModel


class Hmac(BaseModel):
    key: str
    value: str

app = FastAPI()


@app.post('/sig')
def sig(hmac_sha1: Hmac):
    key = bytes(hmac_sha1.key, 'utf-8')
    basestring = bytes(hmac_sha1.value, 'utf-8')
    m = hmac.new(key, basestring, digestmod=hashlib.sha1).digest()
    m_base = base64.b64encode(m)
    result = urllib.parse.quote(m_base, safe='')
    return {"signature": result}


@app.post('/nonce')
def nonce():
    _nonce = secrets.token_urlsafe(32)
    nonce = ''.join(char for char in _nonce if char.isalnum())
    return {"nonce": nonce}

まずは必要なライブラリをインポートしています。

oauth_signatureを返すURLのパスを「/sig」にしています。
ssGTMからはHMACの計算をするのに必要な、signingKey(key)とbaseString(value)を受け取ることを想定しています。

受け取った文字列をバイト列に変換したあと
hmac.newに私でhmacのオブジェクトを生成します。

これをbese64でエンコードして、パーセントエンコードしたものが「oauth_signature」でしたね。

m_base = base64.b64encode(m)

この段階では例えば、以下のような文字列になります。

b'K4yByAeX8ign/1yx+rq+4v8dZ7Y='

ここで、

 result = urllib.parse.quote(m_base, safe='')

を実行することで、パーセントエンコードされて

'K4yByAeX8ign%2F1yx%2Brq%2B4v8dZ7Y%3D'

という形になります。
デフォルトだと「/」はエンコードされませんので、safe=''で空文字にしておきます。

そしてこの結果を辞書型(json形式)で返却しています。

一方、「oauth_nonce」はパスを「/nonce」にしています。

_nonce = secrets.token_urlsafe(32)

この一行で「32バイトのランダムな文字列を生成して、base64でエンコード」までやってくれます。素晴らしい。

で、記号類を除くために以下を実行しています。

nonce = ''.join(char for char in _nonce if char.isalnum())

ようするに_nonceをもとに新しい文字列を生成しています。

_nonceの文字列を先頭から1文字ずつ取り出し、
それが英数字かどうか判定してTrueなら新たな文字列に加える、というイメージです。

(char for char in _nonce if char.isalnum())

はジェネレータを返すので、「''.join」で文字列にしています。
(ジェネレータ内包表記ではなく、リスト内包表記でも問題ないです。)

この値を、oauth_signatureと同様に辞書型で返却しています。

以上がPythonのコードの記述です。

あとはこれをサーバーにデプロイすればOKですね。
GCPでもAWSでもいいと思いますし、もっと手頃なHerokuやDetaでもいいでしょう。

DetaはFastAPIのスポンサーなので、FastAPIとは相性が良いです。
筆者もテストではDetaにデプロイしました。

以上で、
ssGTM 85%、Python(+FastAPI)15%で実装
は完成しました!!

お疲れさまです。

Pythonのコードをデプロイするところなどは省いてしまっていますが、
気になる人が多数いたらそこも追記したいと思います。

一旦、次のGASの方が導入ハードルは低いと思うので、そちらを御覧ください!

Ⅷ. ssGTM + Google Ads Script(GAS)実装

最後はこちらですね。
ssGTM 40%、Google Apps Script(GAS) 60%

ⅦのPythonを使うパターンは
Python側では「oauth_nonce」と「oauth_signature」の生成だけ実行しました。

TwitterのコンバージョンAPIエンドポイントへのリクエスト送信の記述は、ssGTMの方で記載していました。(ssGTM(GCP)→Twitter)

つまり、

ssGTM:  payload作成
ssGTM →→ Python (oauth_nonce計算してくれ)
ssGTM ←← Python (oauth_nonce教えるよ) 
ssGTM →→ Python (oauth_signature計算してくれ)
ssGTM ←← Python (oauth_signature教えるよ)
ssGTM:  Authorizationヘッダー等作成
ssGTM →→ Twitter (CAPIのリクエスト送るよ)

みたいなイメージですね。

本章は、ssGTMでは「payload」の作成だけやり、
Twitterへのリクエスト送信ふくめて、その他はGAS側でやってもらいます。

要するに

ssGTM: payload作成
ssGTM →→ GAS (payload情報渡します)
GAS: oauth_nonceやoauth_signatureふくめて
    Authorizationヘッダー作成
GAS →→ Twitter (CAPIのリクエスト送るよ)

的な感じです!

Ⅶでは、都度Python側で計算してもらいつつ、
ssGTM側に戻していて、根本ルートはssGTM側で処理していました。

本章では、ssGTMでpayloadつくったら、あとの処理はGASに任せる感じになります。

Ⅶと同様にssGTMのテンプレートから、「タグテンプレート>新規」をクリックしていきます。

例のごとく名前は適当に入力します。

「項目」タブでは今回は以下を設定します。

API_KEYなどは、GAS側のスクリプトプロパティに設定するので、
ssGTMから送る必要はありません。

また、Ⅶでも述べたように、hashed_emailも項目に追加していますが、実際にはクライアントGTMからEvent Dataを通して受け取り、そのままカスタムテンプレート内で処理するパターンが多いかなと思います。

ssGTMの記述

ssGTMのコード記述を見ていきましょう。

上記でも記載したように、ssGTMでやることは、
最終的にTwitterへ送るリクエストのボディ(payload)を作成する部分だけです。

先にコードをお見せします。

// テンプレート コードをここに入力します。
const getAllEventData = require('getAllEventData');
const sendHttpRequest = require('sendHttpRequest');
const JSON = require('JSON');
const getCookieValues = require('getCookieValues');
const logToConsole = require('logToConsole');
const toString = require('makeString');


function getContentFromItems(items){
  return items.map((item) => {
      return {
          "content_id": item.item_id,
          "content_name": item.item_name,
          "content_type": item.item_category,
          "content_price": toString(item.price),
          "num_items": item.quantity,
          "content_group_id": item.item_list_id      
      };
  });
}

const eventModel = getAllEventData();

const cvtime = eventModel.isotime;

const payload = {};
payload.conversions = [];

const cvdata = {};
cvdata.conversion_time = eventModel.isotime;
cvdata.event_id = data.event_id || 'tw-hogehoge-fugafuga';

cvdata.identifiers = [];
if (eventModel['x-tw-twclid']) {
  cvdata.identifiers.push({twcid: eventModel['x-tw-twclid']});
}

const hashEmail = eventModel['x-tw-hashed_email'] || data.hashed_email;
if(hashEmail){
  cvdata.identifiers.push({hashed_email: hashEmail});
}

cvdata.value = toString(eventModel.value);
cvdata.price_currency = eventModel.currency;
cvdata.number_item = eventModel['x-tw-cd-num_items'];
cvdata.conversion_id = eventModel.transaction_id;
cvdata.contents = eventModel['x-tw-cd-contents'] || (eventModel.items != null ? getContentFromItems(eventModel.items): null);

payload.conversions.push(cvdata);

const URL = "https://script.google.com/macros/s/hogehogefugafugaaaaaaaaa/exec";

const resCAPI = sendHttpRequest(URL,{
    headers: {'Content-Type': 'application/json'},
    method: 'POST'
}, JSON.stringify(payload)).then((res) => {
    if(res.body.indexOf('TwitterCAPIOK') > 0) {
      data.gtmOnSuccess();
    }else {
      data.gtmOnFailure();
    }
});

随分と記述がスッキリしましたね。

ほとんどの部分がリクエストボディ(payload)を作る記述です。
↓↓このあたりは全部そうですね。

const eventModel = getAllEventData();

const cvtime = eventModel.isotime;

const payload = {};
payload.conversions = [];

const cvdata = {};
cvdata.conversion_time = eventModel.isotime;
cvdata.event_id = data.event_id || 'tw-hogehoge-fugafuga';

cvdata.identifiers = [];
if (eventModel['x-tw-twclid']) {
  cvdata.identifiers.push({twcid: eventModel['x-tw-twclid']});
}

const hashEmail = eventModel['x-tw-hashed_email'] || data.hashed_email;
if(hashEmail){
  cvdata.identifiers.push({hashed_email: hashEmail});
}

cvdata.value = toString(eventModel.value);
cvdata.price_currency = eventModel.currency;
cvdata.number_item = eventModel['x-tw-cd-num_items'];
cvdata.conversion_id = eventModel.transaction_id;
cvdata.contents = eventModel['x-tw-cd-contents'] || (eventModel.items != null ? getContentFromItems(eventModel.items): null);

payload.conversions.push(cvdata);

そして、この情報をGASに送っているのが以下の部分です。

const URL = "https://script.google.com/macros/s/hogehogefugafugaaaaaaaaa/exec";

const resCAPI = sendHttpRequest(URL,{
    headers: {'Content-Type': 'application/json'},
    method: 'POST'
}, JSON.stringify(payload)).then((res) => {
    if(res.body.indexOf('TwitterCAPIOK') > 0) {
      data.gtmOnSuccess();
    }else {
      data.gtmOnFailure();
    }
});

まず、URLについては、後々GASの設定をした際に入手できるものなので、ここでは一旦スルーで大丈夫です。

.then以降の部分について

.then((res) => {
    if(res.body.indexOf('TwitterCAPIOK') > 0) {
      data.gtmOnSuccess();
    }else {
      data.gtmOnFailure();
    }

TwitterへのリクエストはGASに任せたといっても、『それが成功したのか失敗したのか』はssGTMでも把握したいです。

成功したなら、「data.gtmOnsuccess();」を実行しますし、
失敗しているなら、「data.gtmOnFailure();」を実行したいからですね。

これがなにかというと、プレビューを見る際に役立ちます。

「data.gtmOnsuccess();」が実行されれば、プレビューで見た際に「Succeeded」が表示されます。

「data.gtmOnFailure();」が実行されれば、プレビューでは「Failed」になります。

なので、GAS→Twitterのリクエストが成功しているかどうかは、ssGTMでも知っておきたいわけです。

ここではとりあえず、「その判定条件」を書いていると思ってください。
詳細は以下でGASの話をしたときにまた記載します!

では早々にGASの方に行きましょう。

GASでの記述

いよいよ~~~
って感じですが、実は新しいことはほとんどありません。

ほぼほぼ前編の「GAS」で記載したコードと一緒です。
「payload」の部分だけssGTMから受け取ってる感じです。

あとは、Twitterへリクエストした結果、成功か失敗かをssGTM側に返却しているコードを追加しています。

function doPost(e) {

  const tokenJson = {
      API_KEY: ScriptProperties.getProperty('API_KEY'),
      API_KEY_SECRET: ScriptProperties.getProperty('API_KEY_SECRET'),
      ACCESS_TOKEN: ScriptProperties.getProperty('ACCESS_TOKEN'),
      ACCESS_TOKEN_SECRET: ScriptProperties.getProperty('ACCESS_TOKEN_SECRET')
  };

 const payload = JSON.parse(e.postData.getDataAsString());
  const pixelId = payload.conversions[0].event_id.split('-')[1];
  const capiUrl= "https://ads-api.twitter.com/11/measurement/conversions/" + pixelId;


  let oauthParams = {
    oauth_consumer_key: tokenJson.API_KEY,
    oauth_nonce: (new Date().getTime()).toString(),
    oauth_signature: "",
    oauth_signature_method: "HMAC-SHA1",
    oauth_timestamp: Math.floor(new Date().getTime()/1000),
    oauth_token: tokenJson.ACCESS_TOKEN,
    oauth_version: "1.0"
  }


let text="";
for(let k in oauthParams){
  if(k === "oauth_signature"){
    continue;
  }
  text += percentEncode(k) + "%3D" + percentEncode(oauthParams[k]) + "%26";
}
text = text.slice(0,-3);

const signingKey = percentEncode(tokenJson.API_KEY_SECRET) + '&' + percentEncode(tokenJson.ACCESS_TOKEN_SECRET);
const basestring = 'POST&' + percentEncode(getBaseUrl(capiUrl)) + '&' + text;

const oauth_signature = hash_func(basestring, signingKey);
oauthParams['oauth_signature'] = oauth_signature;


//↓↓ヘッダーの配列(の準備)
const headerParams = Object.keys(oauthParams).map((key) => {
  return key + '=' + oauthParams[key];
})

const headers = {
  'Authorization': 'OAuth ' + headerParams.join(','),
  'Content-Type': 'application/json'
}

const option = {
  method: 'post',
  payload:JSON.stringify(payload),
  headers:headers ,
  escaping: false,
}


const response = UrlFetchApp.fetch(capiUrl, option);
if(response.getResponseCode() !== 200) {
   return;
 }
// return ContentService.createTextOutput('OK');
return HtmlService.createHtmlOutput('TwitterCAPIOK');

}

function percentEncode(str) {
    return encodeURIComponent(str)
        .replace(/\!/g, "%21")
        .replace(/\*/g, "%2A")
        .replace(/\'/g, "%27")
        .replace(/\(/g, "%28")
        .replace(/\)/g, "%29");
}

function hash_func(basestring, key){
  const hmacSha1 = Utilities.computeHmacSignature(Utilities.MacAlgorithm.HMAC_SHA_1, basestring, key);
  const result = Utilities.base64Encode(hmacSha1);
  return percentEncode(result);
}

function getBaseUrl(url){
  return url.split('?')[0];
}

↓↓以下の部分は前編の「GAS」の記述と全く同じですね。
なので、今回解説は飛ばします。


  let oauthParams = {
    oauth_consumer_key: tokenJson.API_KEY,
    oauth_nonce: (new Date().getTime()).toString(),
    oauth_signature: "",
    oauth_signature_method: "HMAC-SHA1",
    oauth_timestamp: Math.floor(new Date().getTime()/1000),
    oauth_token: tokenJson.ACCESS_TOKEN,
    oauth_version: "1.0"
  }


let text="";
for(let k in oauthParams){
  if(k === "oauth_signature"){
    continue;
  }
  text += percentEncode(k) + "%3D" + percentEncode(oauthParams[k]) + "%26";
}
text = text.slice(0,-3);

const signingKey = percentEncode(tokenJson.API_KEY_SECRET) + '&' + percentEncode(tokenJson.ACCESS_TOKEN_SECRET);
const basestring = 'POST&' + percentEncode(getBaseUrl(capiUrl)) + '&' + text;

const oauth_signature = hash_func(basestring, signingKey);
oauthParams['oauth_signature'] = oauth_signature;


//↓↓ヘッダーの配列(の準備)
const headerParams = Object.keys(oauthParams).map((key) => {
  return key + '=' + oauthParams[key];
})

const headers = {
  'Authorization': 'OAuth ' + headerParams.join(','),
  'Content-Type': 'application/json'
}

const option = {
  method: 'post',
  payload:JSON.stringify(payload),
  headers:headers ,
  escaping: false,
}

最初の部分

function doPost(e) {

これは
『POSTメソッドでリクエストを受け取ったら、中身の関数が動きます』
ということです。

なので、この書き方は固定です。

前述した、ssGTMの記述で最後にGASにリクエスト送っていますが、
そのリクエストが来たら、このGASのコードが走る、という感じです。

doPost(e)のe(event)の中身を取り出しているのが次のコード。

 const payload = JSON.parse(e.postData.getDataAsString());

文字列として取り出して、JavaScriptのオブジェクトに変換しています。

そこから、pixel_idを抽出しています。
pixel_idを直接ssGTMから送っても良いのですが、
今回はevent_id(tw-hogehoge-fugafuga)という文字列から
「hogehoge」の部分を取り出しています。

 const pixelId = payload.conversions[0].event_id.split('-')[1];

このpixel_idを使って、Twitter コンバージョンAPIのエンドポイントのURLを作ります。

 const capiUrl= "https://ads-api.twitter.com/11/measurement/conversions/" + pixelId;

これで、payloadの準備もURLの準備も完了です。

ヘッダーについては上記のとおり、前編のGASと同様なので問題なしです。

最後、Twitterへリクエスト送るところを見ていきましょう。

const response = UrlFetchApp.fetch(capiUrl, option);
if(response.getResponseCode() !== 200) {
   return;
 }
// return ContentService.createTextOutput('OK');
return HtmlService.createHtmlOutput('TwitterCAPIOK');

まず、「return」がいくつかありますが、この「return」は
「function doPost(e) {}」関数の「return」です。

要するに、ssGTMへレスポンスしている部分です。

if文の中身については、Twitterへリクエストを投げて、
Statuscodeで200が返ってこなければ、何も返さずreturnしています。

「// return ContentService~」の部分はコメントアウトしているので実行されません。(ただこれから説明することに関わるので、敢えて残しています)

returnで文字列を返す場合に「ContentService」が使えるのですが、
これがセキュリティの問題からか、直接レスポンスされないで、リダイレクトされてしまいます。

公式のドキュメントにも記載があります。

セキュリティ上の理由から、コンテンツ サービスから返されたコンテンツは script.google.com から提供されず、script.googleusercontent.com の 1 回限りの URL にリダイレクトされます。つまり、コンテンツ サービスを使用して別のアプリケーションにデータを返す場合は、リダイレクトに従うように HTTP クライアントを構成する必要があります。たとえば、cURL コマンドライン ユーティリティで、フラグ -L を追加します。この動作を有効にする方法の詳細については、HTTP クライアントのドキュメントをご覧ください。

Google Apps Script

この場合、ssGTM側からプレビューを見ると、GASからのレスポンスで302リダイレクトになっています。

「302」が返ってきたら「成功とみなす」という方針でも特に問題ないかと思いますが、ちゃんとレスポンスの文字列を見たい。。。

そのため、ContentServiceじゃなくて、リダイレクトしない「HtmlService」を使っているわけです。

return HtmlService.createHtmlOutput('TwitterCAPIOK');

ここで、「TwitterCAPIOK」という文字列をssGTMに返却しています。

HTMLとして返却されているので、単純な文字列にはなっていないのですが、以下のような文字列としてレスポンスされます。


<!doctype html> <html> <head> <meta name="chromevox" content-script="no"> <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" nonce="t4HEAD~中略~ZQBNAA"><link rel="stylesheet" href="/static/macros/client/css/3713781812-mae_html_css_ltr.css"> <script type="text/javascript" src="/static/macros/client/js/略-warden_bin_i18n_warden__ja.js"></script> </head> <body> <table id="warning-bar-table" class="full_size" cellspacing="0" cellpadding="0"><tr><td><div id="warning" class="warning-bar"></div></td></tr><tr><td style="height: 100%"><iframe id="sandboxFrame" allow="accelerometer *; ambient-light-sensor *; autoplay *; camera *; clipboard-read *; clipboard-write *; encrypted-media *; fullscreen *; geolocation *; gyroscope *; magnetometer *; microphone *; midi *; payment *; picture-in-picture *; screen-wake-lock *; speaker *; sync-xhr *; usb *; web-share *; vibrate *; vr *" sandbox="allow-downloads allow-forms allow-modals allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts allow-top-navigation-by-user-activation"> </iframe> </td></tr></table><script type="text/javascript" nonce="TGjdDN2Ol6gAZ82LTabiFg"> (function() { var el = document.getElementById('sandboxFrame'); el.onload = function() { goog.script.init("\x7b\x22fun~~中略~~SANDBOX\x22,\x22google.script.host.origin\x22:\x22https:\/\/docs.google.com\x22\x7d,\x22actionPrefix\x22:\x22\/macros\/s\/AKfycbwGS_xlym4f49l0_Gy_PQZKnaGSly2ldE6z3PNwIYuCfE5ANI_Ih4UyrdTJSkfbGTn6\x22,\x22userHtml\x22:\x22TwitterCAPIOK\x22,\x22ncc\x22:\x22\x7b\\\x22awhs\\\x22:true\x7d\x22\x7d", "", undefined, true , false , "false", "https:\/\/n-yfhrc4n2fkujaubmvcgclukqqjqikyh7p2ghuei-0lu-script.googleusercontent.com");} el.src = 'https:\/\/n-yfhrc4n2fk~~中略~~ntent.com\/userCodeAppPanel'; }()); </script> </body>

なっがーいですが、途中で「TwitterCAPIOK」の文字列を発見できます。
ここをピンポイントで抽出してもいいのですが、
「この文字列が含まれていたらOK」としましょう、ということです。

ssGTMのコードに戻りますが、それをやっているのがここです。

.then((res) => {
    if(res.body.indexOf('TwitterCAPIOK') > 0) {
      data.gtmOnSuccess();
    }else {
      data.gtmOnFailure();
    }

レスポンスの中に「TwitterCAPIOK」という文字列が含まれているなら、
「data.gtmOnSuccess();」(つまりSucceeded)にしてね、ということ。

逆に含まれていないなら、失敗なので、「data.gtmOnFailure();」ね。

ここまで来たらもう少し。

GASのコードを書き終えたら、
右上から「デプロイ」をクリック。

「種類の選択」で「ウェブアプリ」を選択

「説明文」はなんでもOK

「次のユーザーとして実行」もどちらでも良いのですが
(自分 or ウェブアプリケーションにアクセスしているユーザー)
スクリプトファイル自体のアクセス権が自分(オーナー)のみの場合は、後者を選択するとエラーが出ます。

アクセスできるユーザーは「全員」でOKです。
ここを「自分のみ」とかにしてしまうと、ssGTMからのアクセスが出来ませんので注意してください。

最後に「デプロイ」をクリック。

そうすると、こんな感じの画面になるので、、
ウェブアプリの「URL」をコピーします。
これがGASへのリクエストエンドポイントになります。

そうしたら、ssGTMのコードの方に戻ります。
後から設定するのでスルーで、、と言っていたところですね。

const URL = "https://script.google.com/macros/s/hogehogefugafugaaaaaaaaa/exec";

このURLを設定する箇所に、
今コピーしたURLを貼り付けます。

これで完了です!

おまけ1

GASの「return」のところで、
「ContentService」をつかうと302リダイレクトが返ってくるから扱いづらい、というような話をしましたが、「302が返ってきたということは、GAS→Twitterへのリクエストは成功した」と判定することも可能といえば可能です。

その場合はssGTMの最後のところを以下のようにしてあげればOKです。

JSON.stringify(payload)).then((res) => {
    if(res.statusCode === 302) {
      data.gtmOnSuccess();
    }else {
      data.gtmOnFailure();
    }
});

statusCodeが302なら「data.gtmOnSuccess();」、
それ以外なら「data.gtmOnFailure();」ということですね。

おまけ2

ここまでpython/nodejs/GASの直接実装と
ssGTM + GAS / ssGTM + python の設定を記載してきました。

直接実装はハードル高い、、、
でもssGTMもハードル高いよな~
と思われる方もいるかもしれません。

実はssGTM使わなくても
普段皆さんが使っているクライアントサイドのGTMと
GAS(やpythonなど)との組み合わせでも出来ます。

ただ、クライアントサイドGTMはPOSTメソッドで送れないんですよね。すべてGETメソッドになります。

なので、GETメソッドのパラメータとしてリクエスト情報をGASなどに送信すれば、その情報を使ってGAS⇒Twitterへリクエスト投げることが、一応できます。

ただ、レスポンスをGTM側確認することができないなど、やや使い勝手が悪いです。

また、Twitterの場合は送信する情報として
「hashed_email」か「twclid」少なくとも片方が必須ですが、
ハッシュ化されているとはいえ、email情報をGETのパラメータとして送るのも微妙な感じはします。

仮にクライアントGTMでGETメソッド使う場合は
twclidを用いるのが良いかと思います。

もしssGTMではなく、普通のクライアントGTMを使う方法に興味がありましたら、コメントいただければ別途書いてみたいとも思います。

それでは以上になります。

長い間お付き合いいただきお疲れさまでした!
至らぬ点や間違っている点がございましたら、ぜひコメントください。

おまけ

ここまで書きながら思いました。

ssGTMを使っている時点でほとんどの場合GCPを使用しているかと思いますので、今回他のサーバー(DetaやGASなど)を使わず、GCPのCloud Functionsを使用するのが簡単かもですね!

Cloud Functionsを使っても、特定のURLにリクエスト送れば、計算した結果を返してくれるような設計が可能です。

ここについて詳しくはまた書きたいと思います!

それでは、
Bye,bye.

この記事が気に入ったらサポートをしてみませんか?