/
add_customer_match_user_list.rb
370 lines (330 loc) · 12.9 KB
/
add_customer_match_user_list.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
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
99
100
101
102
103
104
105
106
107
108
109
110
111
112
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
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
#!/usr/bin/env ruby
# Encoding: utf-8
#
# Copyright 2020 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# This example uses Customer Match to create a new user list (a.k.a. audience)
# and adds users to it.
#
# This feature is only available to accounts that meet the requirements described at
# https://support.google.com/adspolicy/answer/6299717.
#
# Note: It may take up to several hours for the list to be populated with users.
# Email addresses must be associated with a Google account.
# For privacy purposes, the user list size will show as zero until the list has
# at least 1,000 users. After that, the size will be rounded to the two most
# significant digits.
require 'optparse'
require 'google/ads/google_ads'
require 'date'
require 'digest'
def add_customer_match_user_list(customer_id, run_job, user_list_id, job_id)
client = Google::Ads::GoogleAds::GoogleAdsClient.new
if job_id.nil?
if user_list_id.nil?
list_name = create_customer_match_user_list(client, customer_id)
else
list_name = client.path.user_list(customer_id, user_list_id)
end
end
add_users_to_customer_match_user_list(client, customer_id, run_job, list_name, job_id)
end
def create_customer_match_user_list(client, customer_id)
# Creates the user list.
operation = client.operation.create_resource.user_list do |ul|
ul.name = "Customer Match List #{(Time.new.to_f * 1000).to_i}"
ul.description = "A list of customers that originated from email and " \
"physical addresses"
# Customer Match user lists can use a membership life span of 10000 to
# indicate unlimited; otherwise normal values apply.
# Sets the membership life span to 30 days.
ul.membership_life_span = 30
ul.crm_based_user_list = client.resource.crm_based_user_list_info do |crm|
crm.upload_key_type = :CONTACT_INFO
end
end
# Issues a mutate request to add the user list and prints some information.
response = client.service.user_list.mutate_user_lists(
customer_id: customer_id,
operations: [operation],
)
# Prints out some information about the newly created user list.
resource_name = response.results.first.resource_name
puts "User list with resource name #{resource_name} was created."
resource_name
end
# [START add_customer_match_user_list]
def add_users_to_customer_match_user_list(client, customer_id, run_job, user_list, job_id)
offline_user_data_service = client.service.offline_user_data_job
job_name = if job_id.nil?
# Creates the offline user data job.
offline_user_data_job = client.resource.offline_user_data_job do |job|
job.type = :CUSTOMER_MATCH_USER_LIST
job.customer_match_user_list_metadata =
client.resource.customer_match_user_list_metadata do |m|
m.user_list = user_list
end
end
# Issues a request to create the offline user data job.
response = offline_user_data_service.create_offline_user_data_job(
customer_id: customer_id,
job: offline_user_data_job,
)
offline_user_data_job_resource_name = response.resource_name
puts "Created an offline user data job with resource name: " \
"#{offline_user_data_job_resource_name}"
offline_user_data_job_resource_name
else
client.path.offline_user_data_job(customer_id, job_id)
end
# Issues a request to add the operations to the offline user data job. This
# example only adds a few operations, so it only sends one
# AddOfflineUserDataJobOperations request. If your application is adding a
# large number of operations, split the operations into batches and send
# multiple AddOfflineUserDataJobOperations requests for the SAME job. See
# https://developers.google.com/google-ads/api/docs/remarketing/audience-types/customer-match#customer_match_considerations
# and https://developers.google.com/google-ads/api/docs/best-practices/quotas#user_data
# for more information on the per-request limits.
response = offline_user_data_service.add_offline_user_data_job_operations(
resource_name: offline_user_data_job_resource_name,
enable_partial_failure: true,
operations: build_offline_user_data_job_operations(client),
)
# Prints errors if any partial failure error is returned.
if response.partial_failure_error
failures = client.decode_partial_failure_error(response.partial_failure_error)
failures.each do |failure|
failure.errors.each do |error|
human_readable_error_path = error
.location
.field_path_elements
.map { |location_info|
if location_info.index
"#{location_info.field_name}[#{location_info.index}]"
else
"#{location_info.field_name}"
end
}.join(" > ")
errmsg = "error occured while adding operations " \
"#{human_readable_error_path}" \
" with value: #{error.trigger.string_value}" \
" because #{error.message.downcase}"
puts errmsg
end
end
end
puts "The operations are added to the offline user data job."
unless run_job
puts "Not running offline user data job #{job_name}, as requested."
return
end
# Issues an asynchronous request to run the offline user data job
# for executing all added operations.
response = offline_user_data_service.run_offline_user_data_job(
resource_name: offline_user_data_job_resource_name
)
puts "Asynchronous request to execute the added operations started."
puts "Waiting until operation completes."
# Offline user data jobs may take 6 hours or more to complete, so instead of
# waiting for the job to complete, retrieves and displays the job status
# once. If the job is completed successfully, prints information about the
# user list. Otherwise, prints the query to use to check the job again later.
check_job_status(
client,
customer_id,
offline_user_data_job_resource_name,
)
end
# [END add_customer_match_user_list]
def print_customer_match_user_list(client, customer_id, user_list)
query = <<~EOQUERY
SELECT user_list.size_for_display, user_list.size_for_search
FROM user_list
WHERE user_list.resource_name = #{user_list}
EOQUERY
response = client.service.google_ads.search_stream(
customer_id: customer_id,
query: query,
)
row = response.first
puts "The estimated number of users that the user list " \
"#{row.user_list.resource_name} has is " \
"#{row.user_list.size_for_display} for Display and " \
"#{row.user_list.size_for_search} for Search."
puts "Reminder: It may take several hours for the user list to be " \
"populated with the users so getting zeros for the estimations is expected."
end
def build_offline_user_data_job_operations(client)
# [START add_customer_match_user_list_2]
# Create a list of unhashed user data records that we will format in the
# following steps to prepare for the API.
raw_records = [
# The first user data has an email address and a phone number.
{
email: 'test@gmail.com',
# Phone number to be converted to E.164 format, with a leading '+' as
# required. This includes whitespace that will be removed later.
phone: '+1 234 5678910',
},
# The second user data has an email address, a phone number, and an address.
{
# Email address that includes a period (.) before the Gmail domain.
email: 'test.2@gmail.com',
# Address that includes all four required elements: first name, last
# name, country code, and postal code.
first_name: 'John',
last_name: 'Doe',
country_code: 'US',
postal_code: '10011',
# Phone number to be converted to E.164 format, with a leading '+' as
# required.
phone: '+1 234 5678911',
},
# The third user data only has an email address.
{
email: 'test3@gmail.com',
},
]
# Create a UserData for each entry in the raw records.
user_data_list = raw_records.map do |record|
client.resource.user_data do |data|
if record[:email]
data.user_identifiers << client.resource.user_identifier do |ui|
ui.hashed_email = normalize_and_hash(record[:email], true)
end
end
if record[:phone]
data.user_identifiers << client.resource.user_identifier do |ui|
ui.hashed_phone_number = normalize_and_hash(record[:phone], true)
end
end
if record[:first_name]
# Check that we have all the required information.
missing_keys = [:last_name, :country_code, :postal_code].reject {|key|
record[key].nil?
}
if missing_keys.empty?
# If nothing is missing, add the address.
data.user_identifiers << client.resource.user_identifier do |ui|
ui.address_identifier = client.resource.offline_user_address_info do |address|
address.hashed_first_name = normalize_and_hash(record[:first_name])
address.hashed_last_name = normalize_and_hash(record[:last_name])
address.country_code = record[:country_code]
address.postal_code = record[:postal_code]
end
end
else
# If some data is missing, skip this entry.
puts "Skipping addition of mailing information because the following keys are missing:" \
"#{missing_keys}"
end
end
end
end
operations = user_data_list.map do |user_data|
client.operation.create_resource.offline_user_data_job(user_data)
end
# [END add_customer_match_user_list_2]
operations
end
def check_job_status(client, customer_id, offline_user_data_job)
query = <<~QUERY
SELECT
offline_user_data_job.id,
offline_user_data_job.status,
offline_user_data_job.type,
offline_user_data_job.failure_reason,
offline_user_data_job.customer_match_user_list_metadata.user_list
FROM
offline_user_data_job
WHERE
offline_user_data_job.resource_name = '#{offline_user_data_job}'
QUERY
row = client.service.google_ads.search(
customer_id: customer_id,
query: query,
).first
job = row.offline_user_data_job
puts "Offline user data job ID #{job.id} with type '#{job.type}' has status: #{job.status}."
case job.status
when :SUCCESS
print_customer_match_user_list(client, customer_id, job.customer_match_user_list_metadata.user_list)
when :FAILED
puts " Failure reason: #{job.failure_reason}"
else
puts " To check the status of the job periodically, use the following GAQL " \
"query with GoogleAdsService.search:"
puts query
end
end
def normalize_and_hash(str, trim_inner_spaces = false)
if trim_inner_spaces
str = str.gsub("\s", '')
end
Digest::SHA256.hexdigest(str.strip.downcase)
end
if __FILE__ == $0
options = {}
# Running the example with -h will print the command line usage.
OptionParser.new do |opts|
opts.banner = sprintf('Usage: %s [options]', File.basename(__FILE__))
opts.separator ''
opts.separator 'Options:'
opts.on('-C', '--customer-id CUSTOMER-ID', String, 'Customer ID') do |v|
options[:customer_id] = v
end
opts.on('-r', '--run-job', 'If true, runs the OfflineUserDataJob after adding operations.' \
'The default value is false.') do |v|
options[:run_job] = v
end
opts.on('-u', '--user-list-id [USER-LIST-ID]', String,
'The ID of an existing user list. If not specified, this example will create a new user list.') do |v|
options[:user_list_id] = v
end
opts.on('-j', '--offline-user-data-job-id [OFFLINE-USER-DATA-JOB-ID]', String,
'The ID of an existing OfflineUserDataJob in the PENDING state. If not specified, this' \
' example will create a new job.') do |v|
options[:job_id] = v
end
opts.separator ''
opts.separator 'Help:'
opts.on_tail('-h', '--help', 'Show this message') do
puts opts
exit
end
end.parse!
begin
add_customer_match_user_list(
options.fetch(:customer_id).tr("-", ""),
options[:run_job],
options[:user_list_id],
options[:job_id],
)
rescue Google::Ads::GoogleAds::Errors::GoogleAdsError => e
e.failure.errors.each do |error|
STDERR.printf("Error with message: %s\n", error.message)
if error.location
error.location.field_path_elements.each do |field_path_element|
STDERR.printf("\tOn field: %s\n", field_path_element.field_name)
end
end
error.error_code.to_h.each do |k, v|
next if v == :UNSPECIFIED
STDERR.printf("\tType: %s\n\tCode: %s\n", k, v)
end
end
raise
end
end