Class: Facebook::AdvertiserApiClient
- Inherits:
-
BaseService
- Object
- BaseService
- Facebook::AdvertiserApiClient
- Defined in:
- app/services/facebook/advertiser_api_client.rb
Overview
Service object: advertiser API client.
Constant Summary collapse
- API_VERSION =
Marketing Graph API version. Meta versions the URL path; bump this
in lockstep when migrating to a newer API version (versions stay
supported for ~24 months, then auto-upgrade with possible breaking
changes — keep an eye on the changelog). 'v25.0'- BASE_URL =
URL for base.
"https://graph.facebook.com/#{API_VERSION}".freeze
- PAGE_LIMIT =
Page size for the campaigns list endpoint. Facebook caps
limitat
100 for campaign listings; well above the realistic active-campaign
count for one ad account. 100- CAMPAIGN_FIELDS =
Fields to request on each campaign — keeps the payload focused and
avoids Meta's "Please reduce the amount of data you're asking for"
errors when a large account has many campaigns. %w[ id name status effective_status objective daily_budget lifetime_budget created_time updated_time ].freeze
- USER_BATCH_SIZE =
Meta caps a Custom Audience users payload at 10,000 rows per request.
10_000
Instance Attribute Summary
Attributes inherited from BaseService
Instance Method Summary collapse
-
#campaigns_with_spend_since(ad_account_id:, token:, since:) ⇒ Set<String>
Campaign ids that recorded any spend on or after
since. -
#create_custom_audience(ad_account_id:, name:, token:, description: 'Heatwave-managed audience') ⇒ Hash
Create an empty user-provided custom audience (members land via #replace_users).
-
#find_custom_audience(ad_account_id:, name:, token:) ⇒ Hash?
Our custom audience with this exact name, or nil.
-
#list_campaigns(ad_account_id:, token:) ⇒ Array<Hash>
List all campaigns on the ad account.
-
#replace_users(audience_id:, schema:, data:, token:) ⇒ Integer
Replace ALL members of a custom audience with
data(a full snapshot, so opt-outs simply vanish). -
#update_custom_audience(audience_id:, token:, description:) ⇒ Hash
Update mutable fields on an existing custom audience — today just the
description(the grey subtitle shown in Audiences Manager; Meta's "Audience label" column has no Marketing API field).
Methods inherited from BaseService
#initialize, #log_debug, #log_error, #log_info, #log_warning, #logger, #process, #tagged_logger
Constructor Details
This class inherits a constructor from BaseService
Instance Method Details
#campaigns_with_spend_since(ad_account_id:, token:, since:) ⇒ Set<String>
Campaign ids that recorded any spend on or after since. Drives
FacebookCampaignSyncWorker Source visibility — a campaign that
hasn't spent within the window is mirrored as an archived Source.
Walks the same cursor pagination as #list_campaigns. The insights
endpoint only returns rows for campaigns with delivery in the range,
so campaigns that never spent simply don't appear.
113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 |
# File 'app/services/facebook/advertiser_api_client.rb', line 113 def campaigns_with_spend_since(ad_account_id:, token:, since:) spenders = Set.new cursor = nil loop do response = connection(token).get("act_#{ad_account_id}/insights") do |req| req.params['level'] = 'campaign' req.params['fields'] = 'campaign_id,spend' req.params['time_range'] = { since: since.iso8601, until: Date.current.iso8601 }.to_json req.params['limit'] = PAGE_LIMIT req.params['after'] = cursor if cursor end unless response.status == 200 body = safe_parse(response.body) err = body.dig('error', 'message') || body['message'] || "HTTP #{response.status}" raise "Facebook::AdvertiserApiClient: campaign insights failed (HTTP #{response.status}): #{err}" end body = safe_parse(response.body) Array(body['data']).each do |row| spenders << row['campaign_id'].to_s if row['spend'].to_f.positive? end next_cursor = body.dig('paging', 'cursors', 'after') break if next_cursor.blank? || body.dig('paging', 'next').blank? break if next_cursor == cursor # defensive — same cursor twice would loop forever cursor = next_cursor end spenders end |
#create_custom_audience(ad_account_id:, name:, token:, description: 'Heatwave-managed audience') ⇒ Hash
Create an empty user-provided custom audience (members land via
#replace_users).
194 195 196 197 198 199 200 |
# File 'app/services/facebook/advertiser_api_client.rb', line 194 def create_custom_audience(ad_account_id:, name:, token:, description: 'Heatwave-managed audience') response = connection(token).post("act_#{ad_account_id}/customaudiences") do |req| req.body = { name:, subtype: 'CUSTOM', customer_file_source: 'USER_PROVIDED_ONLY', description: }.to_json end raise_audience_error(response, 'create_custom_audience') unless response.status == 200 safe_parse(response.body) end |
#find_custom_audience(ad_account_id:, name:, token:) ⇒ Hash?
Our custom audience with this exact name, or nil. ponytail: single page
(limit 1000); an account with more custom audiences would need cursor
walking like #list_campaigns.
158 159 160 161 162 163 164 165 |
# File 'app/services/facebook/advertiser_api_client.rb', line 158 def find_custom_audience(ad_account_id:, name:, token:) response = connection(token).get("act_#{ad_account_id}/customaudiences") do |req| req.params['fields'] = 'id,name,description' req.params['limit'] = 1000 end raise_audience_error(response, 'find_custom_audience') unless response.status == 200 Array(safe_parse(response.body)['data']).find { |a| a['name'] == name } end |
#list_campaigns(ad_account_id:, token:) ⇒ Array<Hash>
List all campaigns on the ad account. Walks Meta's paging.cursors.after
cursor pagination until paging.next stops being present and returns
the concatenated list.
69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 |
# File 'app/services/facebook/advertiser_api_client.rb', line 69 def list_campaigns(ad_account_id:, token:) campaigns = [] cursor = nil loop do response = connection(token).get("act_#{ad_account_id}/campaigns") do |req| req.params['limit'] = PAGE_LIMIT req.params['fields'] = CAMPAIGN_FIELDS.join(',') req.params['after'] = cursor if cursor end unless response.status == 200 body = safe_parse(response.body) err = body.dig('error', 'message') || body['message'] || "HTTP #{response.status}" raise "Facebook::AdvertiserApiClient: list_campaigns failed (HTTP #{response.status}): #{err}" end body = safe_parse(response.body) page = Array(body['data']) campaigns.concat(page) next_cursor = body.dig('paging', 'cursors', 'after') break if next_cursor.blank? || body.dig('paging', 'next').blank? break if next_cursor == cursor # defensive — same cursor twice would loop forever cursor = next_cursor end campaigns end |
#replace_users(audience_id:, schema:, data:, token:) ⇒ Integer
Replace ALL members of a custom audience with data (a full snapshot, so
opt-outs simply vanish). Meta's session flow: chunk into ≤10k-row batches
under one session_id, flagging the last. Rows are pre-hashed and conform to
schema.
ponytail: sends session+payload as a JSON body. If Meta's usersreplace
rejects that for the form-encoded payload=/session= param shape, switch
the encoding here — verify on the first LIVE run (dry-run never calls this).
217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 |
# File 'app/services/facebook/advertiser_api_client.rb', line 217 def replace_users(audience_id:, schema:, data:, token:) session_id = SecureRandom.random_number(1 << 31) batches = data.each_slice(USER_BATCH_SIZE).to_a batches.each_with_index do |batch, i| response = connection(token).post("#{audience_id}/usersreplace") do |req| req.body = { session: { session_id:, batch_seq: i + 1, last_batch_flag: i == batches.size - 1, estimated_num_total: data.size }, payload: { schema:, data: batch } }.to_json end raise_audience_error(response, 'replace_users') unless response.status == 200 end data.size end |
#update_custom_audience(audience_id:, token:, description:) ⇒ Hash
Update mutable fields on an existing custom audience — today just the
description (the grey subtitle shown in Audiences Manager; Meta's
"Audience label" column has no Marketing API field). A partial POST to the
audience node; members are untouched (that's #replace_users).
177 178 179 180 181 182 183 |
# File 'app/services/facebook/advertiser_api_client.rb', line 177 def update_custom_audience(audience_id:, token:, description:) response = connection(token).post(audience_id.to_s) do |req| req.body = { description: }.to_json end raise_audience_error(response, 'update_custom_audience') unless response.status == 200 safe_parse(response.body) end |