diff --git a/README.md b/README.md index 773fee1..4bc77a4 100644 --- a/README.md +++ b/README.md @@ -641,3 +641,30 @@ Tests run against a stubbed transport, so nothing touches the network. ## License MIT. See [LICENSE](LICENSE). + +### Google Ads + +Campaigns, ad groups, ads, audiences and insights are on `client.ads` and dispatch by +connection. What only Google has is under `client.ads.google`: + +```ruby +keywords = client.ads.google.keywords( + connection_id: 'c4d5e6f7-…', + customer_id: '1234567890' +) + +client.ads.google.create_keyword( + workspace_id: '7d2b8c11-…', + connection_id: 'c4d5e6f7-…', + customer_id: '1234567890', + ad_group_id: '1234567890~adGroup~77', + text: 'running shoes', + match_type: 'EXACT' +) +``` + +Also `keyword_ideas`, `keyword_metrics`, `search_terms`, `bid_strategies`, `ad_schedule` +and `set_ad_schedule`, the negative keyword lists, `assets` and `asset_groups`, +`local_services_leads`, the conversion methods, and `query` for a raw read-only GAQL +SELECT. Changes need the `publish` scope as well as `ads`; `customer_id` has to name an +account the connection's grant reaches. diff --git a/lib/fopost/client.rb b/lib/fopost/client.rb index ad568f6..1477e75 100644 --- a/lib/fopost/client.rb +++ b/lib/fopost/client.rb @@ -6,6 +6,7 @@ require 'fopost/resources/account_groups' require 'fopost/resources/accounts' require 'fopost/resources/ads' +require 'fopost/resources/google_ads' require 'fopost/resources/ai' require 'fopost/resources/broadcasts' require 'fopost/resources/contacts' diff --git a/lib/fopost/models.rb b/lib/fopost/models.rb index e918932..8901f97 100644 --- a/lib/fopost/models.rb +++ b/lib/fopost/models.rb @@ -1368,4 +1368,108 @@ class Enrollment < Model # On a skipped step, the reason it was skipped. attribute :error end + + # ─── Google Ads ─────────────────────────────────────────────────── + + # A keyword on an ad group. `id` is `~keyword~~`. + class GoogleKeyword < Model + attribute :id + attribute :ad_group_id + attribute :text + attribute :match_type + attribute :status + # Account currency, minor units. + attribute :cpc_bid_minor + attribute :negative + end + + class GoogleKeywordIdea < Model + attribute :text + attribute :avg_monthly_searches + attribute :competition + attribute :low_top_of_page_bid_minor + attribute :high_top_of_page_bid_minor + end + + class GoogleSearchTerm < Model + attribute :term + attribute :ad_group_id + attribute :status + attribute :metrics + end + + class GoogleBidStrategy < Model + attribute :id + attribute :name + attribute :type + attribute :status + attribute :campaign_count + end + + class GoogleAdScheduleSlot < Model + attribute :id + attribute :day_of_week + attribute :start_hour + attribute :end_hour + attribute :bid_modifier + end + + # A negative keyword list. + class GoogleSharedSet < Model + attribute :id + attribute :name + attribute :type + attribute :member_count + end + + # A sitelink, callout or structured snippet. + class GoogleAsset < Model + attribute :id + attribute :name + attribute :type + attribute :text + attribute :final_url + end + + # Where an asset is attached; an asset with no links serves nowhere. + class GoogleAssetLink < Model + attribute :id + attribute :asset_id + attribute :level + attribute :owner_id + attribute :field_type + attribute :status + end + + # A Performance Max asset group. + class GoogleAssetGroup < Model + attribute :id + attribute :campaign_id + attribute :name + attribute :status + attribute :final_urls + end + + # A lead from Local Services Ads, read live and never stored. + class GoogleLocalServicesLead < Model + attribute :id + attribute :category + attribute :service + attribute :contact_name + attribute :phone + attribute :email + attribute :status + attribute :type + attribute :created_at + end + + class GoogleConversionAction < Model + attribute :id + attribute :name + attribute :category + attribute :status + attribute :type + attribute :counting_type + attribute :value_minor + end end diff --git a/lib/fopost/resources/ads.rb b/lib/fopost/resources/ads.rb index 24d3e66..20a612b 100644 --- a/lib/fopost/resources/ads.rb +++ b/lib/fopost/resources/ads.rb @@ -2,13 +2,20 @@ module Fopost module Resources - # `client.ads` — Meta ads, catalogs, audiences, the ad archive and lead forms. + # `client.ads` — ads, catalogs, audiences, the ad archive and lead forms. + # + # Meta is what this resource covers; the Google-only surface is `client.ads.google`. # # Every method needs the `ads` scope. {#boost}, {#create}, {#set_status} # and {#delete} spend money and also need `publish`, as do the create, # update, delete and duplicate methods for campaigns, ad sets and network # ads, and {#bulk_set_status}. class Ads < Base + # The Search surface no other network has: keywords, assets, conversions, GAQL. + def google + @google ||= GoogleAds.new(http) + end + # Boosts and ads created through FoPost, with insights from their last refresh. def list(workspace_id: nil) parse_list(Ad, unwrap(http.get('/ads', { 'workspace_id' => workspace_id }))) @@ -41,6 +48,14 @@ def authorize_meta(workspace_id:, method: nil, return_to: nil) url.nil? ? '' : url.to_s end + # The Google login URL; the caller finishes it in their own browser. + def authorize_google(workspace_id:, return_to: nil) + body = compact_nil('workspaceId' => workspace_id, 'returnTo' => return_to) + result = unwrap(http.post('/ads/connections/google/authorize', body)) + url = result.is_a?(Hash) ? result['url'] : nil + url.nil? ? '' : url.to_s + end + # Also deletes every ad record created through the connection. def delete_connection(connection_id, workspace_id:) http.request(:delete, "/ads/connections/#{connection_id}", params: { 'workspace_id' => workspace_id }) diff --git a/lib/fopost/resources/google_ads.rb b/lib/fopost/resources/google_ads.rb new file mode 100644 index 0000000..5227ad1 --- /dev/null +++ b/lib/fopost/resources/google_ads.rb @@ -0,0 +1,309 @@ +# frozen_string_literal: true + +module Fopost + module Resources + # `client.ads.google` — the Google Ads surface no other network has. + # + # Campaigns, ad groups, ads, audiences and insights are on `client.ads` + # and dispatch by connection. What is here — keywords, assets, Performance + # Max asset groups, Local Services leads, conversions and raw GAQL — is + # Google only, and a connection on another network answers 400. + # + # Every method needs the `ads` scope; anything that changes what a live + # account serves or bids also needs `publish`. `customer_id` is digits only + # and has to name an account the connection's grant reaches. + class GoogleAds < Base + # ── Keywords ── + + # Keywords on the account, or on one ad group. + def keywords(connection_id:, customer_id:, workspace_id: nil, ad_group_id: nil) + parse_list(GoogleKeyword, unwrap(http.get( + '/ads/google/keywords', + params(connection_id, customer_id, workspace_id, + 'ad_group_id' => ad_group_id) + ))) + end + + # The new keyword's id. Needs `publish` as well as `ads`. + def create_keyword(workspace_id:, connection_id:, customer_id:, ad_group_id:, text:, match_type:, + cpc_bid_minor: nil) + body = scope(workspace_id, connection_id, customer_id).merge(compact_nil( + 'adGroupId' => ad_group_id, + 'text' => text, + 'matchType' => match_type, + 'cpcBidMinor' => cpc_bid_minor + )) + id_of(http.post('/ads/google/keywords', body)) + end + + # `status` is "active" or "paused". Needs `publish` as well as `ads`. + def update_keyword(keyword_id, workspace_id:, connection_id:, customer_id:, status: nil, cpc_bid_minor: nil) + body = scope(workspace_id, connection_id, customer_id).merge(compact_nil( + 'status' => status, + 'cpcBidMinor' => cpc_bid_minor + )) + id_of(http.request(:patch, "/ads/google/keywords/#{keyword_id}", json: body)) + end + + # Needs `publish` as well as `ads`. + def delete_keyword(keyword_id, workspace_id:, connection_id:, customer_id:) + http.request(:delete, "/ads/google/keywords/#{keyword_id}", + json: scope(workspace_id, connection_id, customer_id)) + nil + end + + # Ideas from seed keywords, a landing page, or both. + def keyword_ideas(workspace_id:, connection_id:, customer_id:, seeds: nil, url: nil, language_id: nil, + geo_target_ids: nil) + body = scope(workspace_id, connection_id, customer_id).merge(compact_nil( + 'seeds' => seeds, + 'url' => url, + 'languageId' => language_id, + 'geoTargetIds' => geo_target_ids + )) + parse_list(GoogleKeywordIdea, unwrap(http.post('/ads/google/keyword-ideas', body))) + end + + def keyword_metrics(workspace_id:, connection_id:, customer_id:, keywords:) + body = scope(workspace_id, connection_id, customer_id).merge('keywords' => keywords) + parse_list(GoogleKeywordIdea, unwrap(http.post('/ads/google/keyword-metrics', body))) + end + + # What people actually searched, with the metrics each term earned. + def search_terms(connection_id:, customer_id:, since:, until_date:, workspace_id: nil) + parse_list(GoogleSearchTerm, unwrap(http.get( + '/ads/google/search-terms', + params(connection_id, customer_id, workspace_id, + 'since' => since, 'until' => until_date) + ))) + end + + # ── Bid strategies and ad schedule ── + + def bid_strategies(connection_id:, customer_id:, workspace_id: nil) + parse_list(GoogleBidStrategy, unwrap(http.get( + '/ads/google/bid-strategies', + params(connection_id, customer_id, workspace_id) + ))) + end + + # Needs `publish` as well as `ads`. + def create_bid_strategy(workspace_id:, connection_id:, customer_id:, name:, type:, target_minor: nil) + body = scope(workspace_id, connection_id, customer_id).merge(compact_nil( + 'name' => name, + 'type' => type, + 'targetMinor' => target_minor + )) + id_of(http.post('/ads/google/bid-strategies', body)) + end + + def ad_schedule(connection_id:, customer_id:, campaign_id:, workspace_id: nil) + parse_list(GoogleAdScheduleSlot, unwrap(http.get( + '/ads/google/ad-schedule', + params(connection_id, customer_id, workspace_id, + 'campaign_id' => campaign_id) + ))) + end + + # Replaces every slot on the campaign: Google has no partial edit for a + # schedule. Needs `publish` as well as `ads`. + def set_ad_schedule(workspace_id:, connection_id:, customer_id:, campaign_id:, slots:) + body = scope(workspace_id, connection_id, customer_id).merge('campaignId' => campaign_id, 'slots' => slots) + result = unwrap(http.request(:put, '/ads/google/ad-schedule', json: body)) + result.is_a?(Hash) ? result['slots'].to_i : 0 + end + + # ── Negative keyword lists ── + + def negative_keyword_lists(connection_id:, customer_id:, workspace_id: nil) + parse_list(GoogleSharedSet, unwrap(http.get( + '/ads/google/negative-keywords', + params(connection_id, customer_id, workspace_id) + ))) + end + + # Needs `publish` as well as `ads`. + def create_negative_keyword_list(workspace_id:, connection_id:, customer_id:, name:) + body = scope(workspace_id, connection_id, customer_id).merge('name' => name) + id_of(http.post('/ads/google/negative-keywords', body)) + end + + # How many were added. Needs `publish` as well as `ads`. + def add_negative_keywords(workspace_id:, connection_id:, customer_id:, shared_set_id:, keywords:) + body = scope(workspace_id, connection_id, customer_id).merge( + 'sharedSetId' => shared_set_id, 'keywords' => keywords + ) + result = unwrap(http.post('/ads/google/negative-keywords/keywords', body)) + result.is_a?(Hash) ? result['added'].to_i : 0 + end + + # Needs `publish` as well as `ads`. + def attach_negative_keyword_list(workspace_id:, connection_id:, customer_id:, shared_set_id:, campaign_id:) + body = scope(workspace_id, connection_id, customer_id).merge( + 'sharedSetId' => shared_set_id, 'campaignId' => campaign_id + ) + http.post('/ads/google/negative-keywords/attach', body) + nil + end + + # ── Assets ── + + # Returns `{ assets: [...], links: [...] }`; an asset with no links is in + # the library and serving nowhere. + def assets(connection_id:, customer_id:, workspace_id: nil) + result = unwrap(http.get('/ads/google/assets', params(connection_id, customer_id, workspace_id))) + result = {} unless result.is_a?(Hash) + { + assets: parse_list(GoogleAsset, result['assets']), + links: parse_list(GoogleAssetLink, result['links']) + } + end + + # `spec` is a sitelink, callout or snippet. Needs `publish` as well as `ads`. + def create_asset(workspace_id:, connection_id:, customer_id:, spec:) + body = scope(workspace_id, connection_id, customer_id).merge('spec' => spec) + id_of(http.post('/ads/google/assets', body)) + end + + # Attaches to the account when `campaign_id` is left out. Needs `publish`. + def attach_asset(workspace_id:, connection_id:, customer_id:, asset_id:, field_type:, campaign_id: nil) + body = scope(workspace_id, connection_id, customer_id).merge(compact_nil( + 'assetId' => asset_id, + 'fieldType' => field_type, + 'campaignId' => campaign_id + )) + http.post('/ads/google/assets/attach', body) + nil + end + + # Removes the links that put it under an ad; on Google the asset itself + # is permanent. Needs `publish` as well as `ads`. + def delete_asset(asset_id, workspace_id:, connection_id:, customer_id:) + http.request(:delete, "/ads/google/assets/#{asset_id}", + json: scope(workspace_id, connection_id, customer_id)) + nil + end + + # ── Performance Max asset groups ── + + def asset_groups(connection_id:, customer_id:, workspace_id: nil, campaign_id: nil) + parse_list(GoogleAssetGroup, unwrap(http.get( + '/ads/google/asset-groups', + params(connection_id, customer_id, workspace_id, + 'campaign_id' => campaign_id) + ))) + end + + # Starts paused unless `status` says otherwise. Needs `publish`. + def create_asset_group(workspace_id:, connection_id:, customer_id:, campaign_id:, name:, final_urls:, + status: nil) + body = scope(workspace_id, connection_id, customer_id).merge(compact_nil( + 'campaignId' => campaign_id, + 'name' => name, + 'finalUrls' => final_urls, + 'status' => status + )) + id_of(http.post('/ads/google/asset-groups', body)) + end + + # Needs `publish` as well as `ads`. + def update_asset_group(asset_group_id, workspace_id:, connection_id:, customer_id:, name: nil, status: nil) + body = scope(workspace_id, connection_id, customer_id).merge(compact_nil( + 'name' => name, + 'status' => status + )) + id_of(http.request(:patch, "/ads/google/asset-groups/#{asset_group_id}", json: body)) + end + + # Needs `publish` as well as `ads`. + def delete_asset_group(asset_group_id, workspace_id:, connection_id:, customer_id:) + http.request(:delete, "/ads/google/asset-groups/#{asset_group_id}", + json: scope(workspace_id, connection_id, customer_id)) + nil + end + + # ── Local Services leads ── + + # Read live on every call and never stored by FoPost. + def local_services_leads(connection_id:, customer_id:, since:, until_date:, workspace_id: nil) + parse_list(GoogleLocalServicesLead, unwrap(http.get( + '/ads/google/local-services', + params(connection_id, customer_id, workspace_id, + 'since' => since, 'until' => until_date) + ))) + end + + # ── Conversions ── + + def conversion_actions(connection_id:, customer_id:, workspace_id: nil) + parse_list(GoogleConversionAction, unwrap(http.get( + '/ads/google/conversions', + params(connection_id, customer_id, workspace_id) + ))) + end + + # Needs `publish` as well as `ads`. + def create_conversion_action(workspace_id:, connection_id:, customer_id:, name:, category:, value_minor: nil, + counting_type: nil) + body = scope(workspace_id, connection_id, customer_id).merge(compact_nil( + 'name' => name, + 'category' => category, + 'valueMinor' => value_minor, + 'countingType' => counting_type + )) + id_of(http.post('/ads/google/conversions', body)) + end + + # Offline conversions, matched to a click. Needs `publish` as well as `ads`. + def upload_conversions(workspace_id:, connection_id:, customer_id:, conversions:) + body = scope(workspace_id, connection_id, customer_id).merge('conversions' => conversions) + uploaded(http.post('/ads/google/conversions/upload', body)) + end + + # Needs `publish` as well as `ads`. + def upload_conversion_adjustments(workspace_id:, connection_id:, customer_id:, adjustments:) + body = scope(workspace_id, connection_id, customer_id).merge('adjustments' => adjustments) + uploaded(http.post('/ads/google/conversions/adjustments', body)) + end + + # ── GAQL ── + + # A read-only GAQL SELECT; rows come back exactly as Google returns them. + def query(connection_id:, customer_id:, query:, workspace_id: nil) + body = compact_nil( + 'workspaceId' => workspace_id, + 'connectionId' => connection_id, + 'customerId' => customer_id, + 'query' => query + ) + result = unwrap(http.post('/ads/insights/query', body)) + rows = result.is_a?(Hash) ? result['rows'] : nil + rows.is_a?(Array) ? rows : [] + end + + private + + def scope(workspace_id, connection_id, customer_id) + { 'workspaceId' => workspace_id, 'connectionId' => connection_id, 'customerId' => customer_id } + end + + def params(connection_id, customer_id, workspace_id, extra = {}) + compact_nil({ + 'workspace_id' => workspace_id, + 'connection_id' => connection_id, + 'customer_id' => customer_id + }.merge(extra)) + end + + def id_of(body) + result = unwrap(body) + result.is_a?(Hash) && result['id'] ? result['id'].to_s : '' + end + + def uploaded(body) + result = unwrap(body) + result.is_a?(Hash) ? result['uploaded'].to_i : 0 + end + end + end +end diff --git a/test/google_ads_test.rb b/test/google_ads_test.rb new file mode 100644 index 0000000..187c0d0 --- /dev/null +++ b/test/google_ads_test.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'test_helper' + +class GoogleAdsTest < Minitest::Test + include ClientHelpers + + SCOPE = { workspace_id: 'ws_1', connection_id: 'conn_1', customer_id: '1234567890' }.freeze + + KEYWORD_FIXTURE = { + 'id' => '1234567890~keyword~77~99', + 'adGroupId' => '1234567890~adGroup~77', + 'text' => 'running shoes', + 'matchType' => 'EXACT', + 'status' => 'ENABLED', + 'cpcBidMinor' => 180, + 'negative' => false + }.freeze + + def test_keywords_name_the_connection_and_the_customer + transport.stub(:get, '/ads/google/keywords', json: { 'data' => [KEYWORD_FIXTURE] }) + + keywords = client.ads.google.keywords( + connection_id: 'conn_1', customer_id: '1234567890', ad_group_id: '1234567890~adGroup~77' + ) + + assert_equal 'running shoes', keywords[0].text + assert_equal 180, keywords[0].cpc_bid_minor + query = transport.last.query + + assert_equal 'conn_1', query['connection_id'] + assert_equal '1234567890', query['customer_id'] + assert_equal '1234567890~adGroup~77', query['ad_group_id'] + end + + def test_create_keyword_sends_a_camel_case_body + transport.stub(:post, '/ads/google/keywords', status: 201, + json: { 'data' => { 'id' => '1234567890~keyword~77~99' } }) + + id = client.ads.google.create_keyword( + **SCOPE, ad_group_id: '1234567890~adGroup~77', text: 'running shoes', match_type: 'EXACT' + ) + + assert_equal '1234567890~keyword~77~99', id + body = transport.last.json + + assert_equal '1234567890~adGroup~77', body['adGroupId'] + assert_equal 'EXACT', body['matchType'] + assert_equal '1234567890', body['customerId'] + end + + def test_delete_carries_the_scope_in_the_body + transport.stub(:delete, '/ads/google/assets/1234567890~asset~4321', status: 204) + + client.ads.google.delete_asset('1234567890~asset~4321', **SCOPE) + + assert_equal( + { 'workspaceId' => 'ws_1', 'connectionId' => 'conn_1', 'customerId' => '1234567890' }, + transport.last.json + ) + end + + def test_ad_schedule_is_replaced_with_put + transport.stub(:put, '/ads/google/ad-schedule', json: { 'data' => { 'slots' => 2 } }) + + slots = client.ads.google.set_ad_schedule( + **SCOPE, + campaign_id: '1234567890~campaign~55', + slots: [{ 'dayOfWeek' => 'MONDAY', 'startHour' => 9, 'endHour' => 18 }] + ) + + assert_equal 2, slots + assert_equal 'PUT', transport.last.method.to_s.upcase + end + + def test_query_returns_rows_as_google_sends_them + transport.stub(:post, '/ads/insights/query', + json: { 'data' => { 'rows' => [{ 'campaign' => { 'id' => '55' } }] } }) + + rows = client.ads.google.query( + connection_id: 'conn_1', customer_id: '1234567890', query: 'SELECT campaign.id FROM campaign' + ) + + assert_equal [{ 'campaign' => { 'id' => '55' } }], rows + end + + def test_authorize_google_has_its_own_route + transport.stub(:post, '/ads/connections/google/authorize', + json: { 'data' => { 'url' => 'https://accounts.google.com/o/x' } }) + + assert_equal 'https://accounts.google.com/o/x', client.ads.authorize_google(workspace_id: 'ws_1') + end +end