【PHP】GoogleAPIで操作履歴を取りたい！

#はじめに

運用担当者から連絡があり、
「入札とかいろんな変更履歴をAPIから自動で取りたい」
とのご要望をいただきました。

履歴とな！！？
と思わず管理画面チェックしました。
そうか、あいつか。。

Google広告の管理画面の左にヒッソリと、
そう実にヒッソリといるあいつですね。。

![キャプチャ.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/566860/b403c272-8972-6a01-3950-2991db4c1d0c.png)

なるほど操作を一つ一つ記録してくれているなんて、さすが。
画面からは２年前の変更履歴まで確認できるらしいです。
Googleのドキュメントは[こちら](https://support.google.com/google-ads/answer/2454137?hl=ja)

#変更履歴取得リソースChangeEvent

GoogleAPIのversion6.0.0より、
変更履歴の情報をAPIで取得できるようになりました。

リソース名はChangeEventです。

詳細は[リリースノート](https://developers.google.com/google-ads/api/docs/release-notes?hl=ja#v6)参照

むしろ割と最近までAPIでは履歴取れなかったんだなあ。
まあどうしても必要な情報ってわけではないから、あとまわしにされてたのかな？

ということで
出来たてほやほや（ってほどでももうないけど）の
ChangeEventを使ってみることにする。。

#前提

まずはおなじみのoAuth2認証。

認証に関しては、過去記事参照のこと。
[【PHP】Google Ads APIをサンプルプログラム無しで使いたい！！](https://qiita.com/azasaz/items/9a64c7c8d56d6692788b)

リフレッシュトークンとクライアントID、クライアントシークレットを使ってoAuth2認証を行い、
アクセストークンを取るところまで。

アクセストークンが取れたら、ヘッダーに認証情報をセットします。

```
//ヘッダー情報をセット
$_header = [
    "Content-Type: application/json",
    "Accept: application/json",
    "Authorization: Bearer $access_token", 
    "developer-token: $developer_token",
    "login-customer-id: $login_customer_id",
];
```
準備おーけー。

#クエリ言語でデータ取得

ChangeEventはレポートと同じようにreadonlyのリソースで、
Googleクエリ言語でアクセスすれば簡単に取れます。

[クエリ言語](https://developers.google.com/google-ads/api/docs/query/overview?hl=ja)はSQLに似たAPI照会方法で、
「SELECT～」の構文を作成して、
APIにgoogleAds:searchでアクセスします。

GoogleAPIではgetというメソッドも一応あるけど、
デバッグとテスト用にあるだけで推奨されていなくて、
参照だけであれば基本searchか SearchStreamを使うように[記載](https://developers.google.com/google-ads/api/rest/common/get)があります。
（SearchStreamは使ったことない）

クエリ言語に関してはGoogleさんがドキュメントもたくさん用意してくれているし、
[クエリビルダー](https://developers.google.com/google-ads/api/fields/v7/change_event_query_builder?hl=ja)って機能でデモ的に構文まで全部作れちゃうから、
使い慣れるととても便利！

SQLみたいにテーブルとテーブルをJOINしたりはできなくて、
１リソースに１アクセスしなければいけないので注意。

ただし、一つのリソースしか指定していなくても
API内部で紐付いていて、値が取れるものもあります。

change_eventであれば、

*ad_group
*campaign
*customer
*feed
*feed_item

のリソースは取れます。

![キャプチャ2.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/566860/620ba130-b70f-ddcb-1603-1548975c4971.png)

どのリソースが取れるかはそれぞれ違うので、
使いたいもののドキュメントを参照しましょう。
私はいつも英語との格闘になる・・・

今回はChangeEventの全項目を取得するためのクエリを作成します。
クエリでは、リソース名「ChangeEvent」ではなく、「change_event」と指定します。

リソースによってWHERE条件文やLIMITなど必須項目が異なります。
change_eventの場合は、

*WHEREに期間を指定すること（履歴は過去３０日分しか取れないので、その有効値）
*LIMITで上限件数を指定すること

の２つが必須条件らしい。

```
$_query_str = "
SELECT
     campaign.id
    ,campaign.name
    ,campaign.bidding_strategy_type
    ,ad_group.id
    ,ad_group.name
    ,change_event.resource_name
    ,change_event.change_date_time
    ,change_event.change_resource_type
    ,change_event.change_resource_name
    ,change_event.client_type
    ,change_event.user_email
    ,change_event.old_resource
    ,change_event.new_resource
    ,change_event.resource_change_operation
    ,change_event.changed_fields
    ,change_event.campaign
    ,change_event.ad_group
    ,change_event.feed
    ,change_event.feed_item
FROM change_event
WHERE
	change_event.change_date_time >= '2021-05-01 00:00:00' AND change_event.change_date_time <= '2021-05-10 23:59:59'
LIMIT 10000
";

//クエリをセット
$_query = ["query" => $_query_str];

//URLをセット
//※versionやcustomeridは環境に合わせてセットすること 2021/5現在の最新versionはv7
$_url = "https://googleads.googleapis.com/v7/customers/$customerid/googleAds:search";

//curl START
$_curl = curl_init();

//OPTIONをセット
curl_setopt_array($_curl, array(
    CURLOPT_URL => $_url,
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 120,
    CURLOPT_HTTPHEADER => $_header,
    CURLOPT_POSTFIELDS => json_encode($_query),
));

//curl EXEC(文字列で取得)
$_resp = curl_exec($_curl);

//エラーハンドリング用
$_errno = curl_errno($_curl);

//curl END
curl_close($_curl);

//エラーハンドリング
if ($_errno !== CURLE_OK) {
	//エラー処理
}

//エラーでなければjsonを連想配列化
$_result_json = json_decode($_resp, true); 
$_result = is_array($_result_json["results"]) ? $_result_json["results"] : [];

//履歴情報を整理
foreach ($_result as $_idx => $_lineval) {
    if (!is_array($_lineval)) continue;
    
    //キャンペーン情報
    $_campid = $_lineval["campaign"]["id"];
    $_campname = $_lineval["campaign"]["name"];

    //広告グループ情報
    $_adgroupid = $_lineval["adGroup"]["id"];
    $_adgroupname = $_lineval["adGroup"]["name"];

    //履歴情報
    $_resourcename = $_lineval["changeEvent"]["resourceName"];
    $_changedt = $_lineval["changeEvent"]["changeDateTime"];
    $_changertype = $_lineval["changeEvent"]["changeResourceType"]; //AD/AD_GROUP/AD_GROUP_AD/CAMPAIGN/CAMPAIGN_BUDGETなど
    $_changername = $_lineval["changeEvent"]["changeResourceName"];
    $_clienttype = $_lineval["changeEvent"]["clientType"];	//画面かAPIか自動ルールかなど
    $_useremail = $_lineval["changeEvent"]["userEmail"];
    $_oldresource = $_lineval["changeEvent"]["oldResource"];	//旧リソース
    $_newresource = $_lineval["changeEvent"]["newResource"];	//新リソース
    $_rchangeop = $_lineval["changeEvent"]["resourceChangeOperation"];  //CREATE/UPDATE/REMOVE
    $_changefields = $_lineval["changeEvent"]["changedFields"];	//変更対象フィールド
}
```

１つのキャンペーンまたは広告グループごと、操作（登録・更新・削除）ごとにデータが来るようです。
１つの広告グループで複数の項目を更新した場合、１レコードで来ることになります。

ここまでは今まで通りというか割と簡単だなあと思っていたのだけど、
実際のデータを確認してみると想像していたよりかなり複雑だと感じたので、
以下の項目でまとめようと思いました。

リリースされてそれほど経ってないので、おそらく不具合もちらほらあるような。。


#履歴データの分析


たとえば広告グループAの単価設定を「目標コンバージョン単価：1000円」と設定した場合、
APIから来るデータはこんなかんじ。
（名称やコードは適当、実際は対応した値が入ります）

>["campid"]=> "1234567890"
>["campname"]=> "テストキャンペーン"
>["adgroupid"]=> "9999999999"
>["adgroupname"]=> "広告グループA"
>["resourceName"]=> "customers/123456789/changeEvents/11111111111111111~0~0"
>["changeDateTime"]=> "2021-05-12 20:00:46.462725"
>["changeResourceType"]=> "AD_GROUP"
>["changeResourceName"]=> "customers/123456789/adGroups/9999999999"
>["clientType"]=> "GOOGLE_ADS_AUTOMATED_RULE"
>["userEmail"]=> "testtesttest@test.com"
>["oldResource"]=> {["adGroup"]=> {["targetCpaMicros"]=> "1400000000"}}
>["newResource"]=> {["adGroup"]=> {["targetCpaMicros"]=> "1000000000"}}
>["resourceChangeOperation"]=> "UPDATE" 
>["changedFields"]=> "targetCpaMicros"

changeResourceTypeに属するchangedFieldsを更新しました、という情報です。
上の例だとAD_GROUPの中にあるtargetCpaMicrosという項目が対象。

Googleのドキュメントでad_groupを調べてみるとtarget_cpa_microsというやつがちゃんといますね。。

![キャプチャ3.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/566860/4150d5a8-e1fe-c55a-6a0c-ded0ad5142d4.png)

このときの変更履歴は、
oldResourceとnewResourceの中に、changeResourceTypeに対応するリソース名⇒changedFieldsの構造で入ってます。

>oldResource["adGroup"]["targetCpaMicros"] = "1400000000"
>newResource["adGroup"]["targetCpaMicros"] = "1000000000"
>※Microsで来るので÷1000000したのが変更値

つまりこの履歴だと、
Googleの自動ルールが目標コンバージョン単価を1400円⇒1000円に変更しました。
って感じで内容が分かるのです。

メンドクサイのはここから。
changedFieldsには、複数項目が来る場合があります。
前述のように、１つのキャンペーンや広告グループに対して複数の更新を行った場合の履歴は、
以下のようにな感じにデータが来ます。

例えば広告グループのキーワードを追加した場合。

>["campid"]=> "1234567890"
>["campname"]=> "テストキャンペーン"
>["adgroupid"]=> "9999999999" 
>["adgroupname"]=> "広告グループA"
>["resourceName"]=> "customers/123456789/changeEvents/1616161616161616~0~1"
>["changeDateTime"]=> "2021-05-11 18:01:05.247115"
>["changeResourceType"]=> "AD_GROUP_CRITERION"
>["changeResourceName"]=> "customers/123456789/adGroupCriteria/111111111111~333333333333"
>["clientType"]=> "GOOGLE_ADS_WEB_CLIENT" 
>["userEmail"]=> "testtesttest@test.com" 
>["oldResource"]=> {["adGroupCriterion"]=> { }}
>["newResource"]=> {["adGroupCriterion"]=> {
>　["resourceName"]=> "customers/123456789/adGroupCriteria/111111111111~333333333333"
>　["keyword"]=> {	["matchType"]=> "BROAD"	["text"]=> "テストキーワード"}
>　["criterionId"]=> "333333333333"
>　["adGroup"]=> "customers/123456789/adGroups/9999999999"
>　["negative"]=> bool(false)
>}}
>["resourceChangeOperation"]=> "CREATE"
>["changedFields"]=> "adGroup,criterionId,keyword.matchType,keyword.text,negative,resourceName"

今回はresourceChangeOperationがCREATEなのでoldResourceはカラです。
同じようにresourceChangeOperationがREMOVEの場合はnewResourceがカラで来ます。
oldとnew両方に値が入ってるのはUPDATEだけということになります。

この例では、ad_group_criterionの
adGroup,criterionId,keyword.matchType,keyword.text,negative,resourceNameと
６種類のフィールドを変更したことになります。

newResourceを見てみると、
adGroupCriterionの下に５つ（keywordの下には更に２つ）の構造になっていますね。。

このようにchangedFieldsは、複数フィールドの場合はカンマ区切りで来ます。
更にkeyword.matchTypeのようにカンマが入っているのは、
更に下に階層構造を持っていることを意味します。

これがフィールドによって階層構造が異なるのでホントやっかい。
特定のフィールドのものだけ取ろうとするならまあいいとして、
全部の履歴を取るならいちいち場合分けをして配列の中身を取り出さないといけません。
もう少し簡単に作ってほしかった。。

ものによって階層が違うのでどうすべきか散々悩んだ結果、
jsonに頑張って書くことでなんとか落ち着きました。
もっとスマートにできるアイディアがあればいいんだけど・・・

ということでそのjsonに規定した内容でだいたいの階層構造が分かると思うので
参考までにここに載せておきます。
"class"とあるフィールドは階層構造になっているということ。
"#num"とあるところは配列になっていて０～の数字が入ります。
"end"は階層構造の最後という目印。(ないのもあるけど便宜上仕方なかったやつ)

```setting.json
"changed_fields" : {
"$comment": "#---------------変更フィールド：値によって構造が異なるので注意※[#num]となっている項目は配列の数値が入る",

"$comment_default": "#---------------共通",
"name" : {"name" : "名称"},
"status" : {"name" : "ステータス"},
"bidModifier" : {"name" : "入札調整比"},

"$comment_ad": "#---------------[ad]用",
"responsiveSearchAd.headlines" : {
    "name" : "広告見出し", 
    "class" : {"#num.text" : "end"}
},
"responsiveSearchAd.descriptions" : {
    "name" : "説明文",
    "class" : {"#num.text" : "end"}
},
"responsiveSearchAd.path1" : {"name" : "パス1"},
"responsiveSearchAd.path2" : {"name" : "パス2"},
"expandedTextAd.headlinePart1" : {"name" : "広告見出し1"},
"expandedTextAd.headlinePart2" : {"name" : "広告見出し2"},
"expandedTextAd.headlinePart3" : {"name" : "拡張テキスト広告"},
"expandedTextAd.description" : {"name" : "説明文1"},
"expandedTextAd.description2" : {"name" : "説明文2"},
"expandedTextAd.path1" : {"name" : "パス1"},
"expandedTextAd.path2" : {"name" : "パス2"},
"finalUrls" : {
    "name" : "最終ページURL",
    "class" : {"#num" : "end"}
},
"urlCustomParameters" : {
    "name" : "URLカスタムパラメータ",
    "class" : {"#num.key" : "end", "#num.value" : "end"}
},
"finalMobileUrls" : {
    "name" : "モバイル用最終ページURL",
    "class" : {"#num" : "end"}
},
"trackingUrlTemplate" : {"name" : "トラッキングテンプレート"},
"finalUrlSuffix" : {"name" : "最終ページURLサフィックス"},

"$comment_campaign": "#---------------[campaign]用",
"networkSettings.targetContentNetwork" : {"name" : "ネットワーク設定：Googleディスプレイネットワーク"},
"networkSettings.targetGoogleSearch" : {"name" : "ネットワーク設定：Google検索パートナー"},
"targetCpa.targetCpaMicros" : {"name" : "キャンペーン目標コンバージョン単価"},
"targetSpend.cpcBidCeilingMicros" : {"name" : "上限CPC"},
"targetRoas.targetRoas" : {"name" : "キャンペーン目標費用対効果"},
"manualCpc.enhancedCpcEnabled" : {"name" : "拡張クリック単価"},
"targetImpressionShare.cpcBidCeilingMicros" : {"name" : "上限CPC"},
"targetImpressionShare.location" : {"name" : "広告掲載場所"},
"targetImpressionShare.locationFractionMicros" : {"name" : "目標インプレッションシェア"},
"startDate" : {"name" : "開始日"},
"endDate" : {"name" : "終了日"},
"adServingOptimizationStatus" : {"name" : "広告のローテーション"},
"paymentMode" : {"name" : "支払いモード"},
"advertisingChannelType" : {"name" : "広告チャンネルタイプ"},

"$comment_campaignBudget": "#---------------[campaignBudget]用",
"amountMicros" : {"name" : "予算額"},

"$comment_campaignCriterion": "#---------------[campaignCriterion]用",
"adSchedule.dayOfWeek" : {"name" : "スケジュール[曜日]"},
"adSchedule.endHour" : {"name" : "スケジュール[終了(時)]"},
"adSchedule.endMinute" : {"name" : "スケジュール[終了(分)]"},
"adSchedule.startHour" : {"name" : "スケジュール[開始(時)]"},
"adSchedule.startMinute" : {"name" : "スケジュール[開始(分)]"},

"$comment_criterion": "#---------------[criterion]用",
"ageRange.type" : {"name" : "ユーザー属性(年齢)"},
"gender.type" : {"name" : "ユーザー属性(性別)"},
"incomeRange.type" : {"name" : "ユーザー属性(世帯収入)"},
"parentalStatus.type" : {"name" : "ユーザー属性(親のステータス)"},
"keyword.matchType" : {"name" : "キーワードマッチタイプ"},
"keyword.text" : {"name" : "キーワード"},

"$comment_adGroup": "#---------------[adGroup]用",
"targetCpaMicros" : {"name" : "目標コンバージョン単価"},
"cpcBidMicros" : {"name" : "上限クリック単価"},
"cpmBidMicros" : {"name" : "上限CPM"},
"cpvBidMicros" : {"name" : "上限広告視聴単価"},
"targetCpmMicros" : {"name" : "目標インプレッション単価"},
"targetRoas" : {"name" : "目標費用対効果"},
"adRotationMode" : {"name" : "広告のローテーション"},
"targetingSetting.targetRestrictions" : {
    "name" : "ターゲティングの制限",
    "class" : {
        "#num.targetingDimension" : {"name" : "ターゲティングディメンション"},
        "#num.bidOnly" : {"name" : "ターゲティング設定"}
    }
},

"$comment_adGroupAd": "#---------------[adGroupAd]用",
"ad" : {
    "name" : "広告詳細", 
    "class" : {
        "responsiveSearchAd.headlines" : {
            "name" : "広告見出し", 
            "class" : {"#num.text" : "end"}
        },
        "responsiveSearchAd.descriptions" : {
            "name" : "説明文",
            "class" : {"#num.text" : "end"}
        },
        "responsiveSearchAd.path1" : {"name" : "パス1"},
        "responsiveSearchAd.path2" : {"name" : "パス2"},
        "expandedTextAd.headlinePart1" : {"name" : "広告見出し1"},
        "expandedTextAd.headlinePart2" : {"name" : "広告見出し2"},
        "expandedTextAd.headlinePart3" : {"name" : "拡張テキスト広告"},
        "expandedTextAd.description" : {"name" : "説明文1"},
        "expandedTextAd.description2" : {"name" : "説明文2"},
        "expandedTextAd.path1" : {"name" : "パス1"},
        "expandedTextAd.path2" : {"name" : "パス2"},
        "finalUrls" : {
            "name" : "最終ページURL",
            "class" : {"#num" : "end"}
        },
        "urlCustomParameters" : {
            "name" : "URLカスタムパラメータ",
            "class" : {"#num" : {"key" : "end", "value" : "end"}}
        },
        "finalMobileUrls" : {
            "name" : "モバイル用最終ページURL",
            "class" : {"#num" : "end"}
        },
        "trackingUrlTemplate" : {"name" : "トラッキングテンプレート"},
        "finalUrlSuffix" : {"name" : "最終ページURLサフィックス"}
    }
},

    "$comment_adGroupBidModifier": "#---------------[adGroupBidModifier]用",
    "device.type" : {"name" : "デバイス"}
},

```

ad_group_adだけは階層の中身が複雑で、入れ子のさらに入れ子になってました。
データなかなか取れなくて苦労したわ・・・

上記は自分に関係ある内容だけであって、取れるフィールド全てではありません。
FEEDとかはいじってないので、どんな値が来るのかよくわかりません。。

GoogleAdsの方でも割と親切にドキュメントは用意してくれてるんだけど、
情報が散らばっちゃってどこ見ればいいの？って状態に私はなってしまったので、
構造をパッと見で理解しやすくなるんじゃないかな、と。。。

#不具合かな？と思われる内容

*clientTypeがGOOGLE_ADS_EDITOR（GoogleAdsEditorで編集したもの）はAPIとしてデータが来ない

全部のデータがあるわけじゃないなあと調べてみたら、EDITORのデータはごっそりありませんでした。
調べてみたら、不具合で対応中？

https://groups.google.com/g/adwords-api/c/sQgHw3r0vuQ/m/el3ploC9CgAJ

2021/05/13現在、まだ取れない。。

*キャンペーンの設定で（changeResourceType:Campaign）目標・地域・言語を変更しても
APIからデータが来ない。

*キャンペーンの設定で（change_resource_type:campaignCriterion）入札単価調整の数値を入力しても
カラデータが来る（たぶんchanged_fieldsはbidModifier）


#おわりに

なんとなく、調べながらも「これほんとに合ってるのかな？」と不安になるようなことが多々あって
まだ安定版じゃないのかなあという印象を持ってます。

最初の例に上げた目標コンバージョン価格とか、
よく使われる広告グループの数値系はちゃんとしてるっぽい？ので、
参照するフィールドは限定して、正しそうなものを選んで使っていくのがいいのかなと感じました。


おしまい。