fopost 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +181 -0
- data/README.md +645 -2
- data/lib/fopost/client.rb +33 -1
- data/lib/fopost/errors.rb +25 -0
- data/lib/fopost/file_part.rb +73 -0
- data/lib/fopost/http/client.rb +36 -4
- data/lib/fopost/http/multipart.rb +38 -0
- data/lib/fopost/models.rb +2104 -0
- data/lib/fopost/platforms.rb +27 -0
- data/lib/fopost/resources/account_groups.rb +37 -0
- data/lib/fopost/resources/accounts.rb +445 -3
- data/lib/fopost/resources/activity.rb +27 -0
- data/lib/fopost/resources/ads.rb +1097 -0
- data/lib/fopost/resources/analytics.rb +63 -0
- data/lib/fopost/resources/blog.rb +206 -0
- data/lib/fopost/resources/blogs.rb +115 -0
- data/lib/fopost/resources/broadcasts.rb +189 -0
- data/lib/fopost/resources/contacts.rb +136 -0
- data/lib/fopost/resources/google_ads.rb +346 -0
- data/lib/fopost/resources/google_business.rb +166 -0
- data/lib/fopost/resources/inbox.rb +224 -0
- data/lib/fopost/resources/knowledge.rb +74 -0
- data/lib/fopost/resources/media.rb +56 -0
- data/lib/fopost/resources/posts.rb +75 -4
- data/lib/fopost/resources/validate.rb +37 -0
- data/lib/fopost/resources/whatsapp.rb +255 -0
- data/lib/fopost/version.rb +1 -1
- data/lib/fopost/webhooks.rb +97 -0
- data/lib/fopost.rb +2 -0
- metadata +19 -1
|
@@ -0,0 +1,1097 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fopost
|
|
4
|
+
module Resources
|
|
5
|
+
# `client.ads` — ads, catalogs, audiences, the ad archive and lead forms.
|
|
6
|
+
#
|
|
7
|
+
# The connection decides which network a call reaches. Meta is what this
|
|
8
|
+
# resource documents; the Google-only surface is `client.ads.google`, and
|
|
9
|
+
# `#providers` reports what each network supports.
|
|
10
|
+
#
|
|
11
|
+
# Every method needs the `ads` scope. {#boost}, {#create}, {#set_status}
|
|
12
|
+
# and {#delete} spend money and also need `publish`, as do the create,
|
|
13
|
+
# update, delete and duplicate methods for campaigns, ad sets and network
|
|
14
|
+
# ads, and {#bulk_set_status}.
|
|
15
|
+
class Ads < Base
|
|
16
|
+
# The Search surface no other network has: keywords, assets, conversions, GAQL.
|
|
17
|
+
def google
|
|
18
|
+
@google ||= GoogleAds.new(http)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Boosts and ads created through FoPost, with insights from their last refresh.
|
|
22
|
+
def list(workspace_id: nil)
|
|
23
|
+
parse_list(Ad, unwrap(http.get('/ads', { 'workspace_id' => workspace_id })))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Ads on the connected ad accounts that were made elsewhere. Read live, never stored.
|
|
27
|
+
def external(workspace_id: nil)
|
|
28
|
+
parse_list(ExternalAd, unwrap(http.get('/ads/external', { 'workspace_id' => workspace_id })))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def boostable(workspace_id: nil)
|
|
32
|
+
parse_list(BoostablePost, unwrap(http.get('/ads/boostable', { 'workspace_id' => workspace_id })))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def connections(workspace_id: nil)
|
|
36
|
+
parse_list(AdConnection, unwrap(http.get('/ads/connections', { 'workspace_id' => workspace_id })))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Each connection with the ad accounts and Pages its grant reaches.
|
|
40
|
+
def sources(workspace_id: nil)
|
|
41
|
+
parse_list(AdSource, unwrap(http.get('/ads/sources', { 'workspace_id' => workspace_id })))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The ad networks this deployment knows, with what each one supports.
|
|
45
|
+
def providers
|
|
46
|
+
parse_list(AdProvider, unwrap(http.get('/ads/providers')))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The network's login URL; the caller finishes it in their own browser.
|
|
50
|
+
# `method` is one of the network's own `connect_methods`.
|
|
51
|
+
def authorize(provider, workspace_id:, method: nil, return_to: nil)
|
|
52
|
+
body = compact_nil('workspaceId' => workspace_id, 'method' => method, 'returnTo' => return_to)
|
|
53
|
+
result = unwrap(http.post("/ads/connections/#{provider}/authorize", body))
|
|
54
|
+
url = result.is_a?(Hash) ? result['url'] : nil
|
|
55
|
+
url.nil? ? '' : url.to_s
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Deprecated: use `authorize("meta", ...)`.
|
|
59
|
+
def authorize_meta(workspace_id:, method: nil, return_to: nil)
|
|
60
|
+
authorize('meta', workspace_id: workspace_id, method: method, return_to: return_to)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The Google login URL; the caller finishes it in their own browser.
|
|
64
|
+
def authorize_google(workspace_id:, return_to: nil)
|
|
65
|
+
body = compact_nil('workspaceId' => workspace_id, 'returnTo' => return_to)
|
|
66
|
+
result = unwrap(http.post('/ads/connections/google/authorize', body))
|
|
67
|
+
url = result.is_a?(Hash) ? result['url'] : nil
|
|
68
|
+
url.nil? ? '' : url.to_s
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Also deletes every ad record created through the connection.
|
|
72
|
+
def delete_connection(connection_id, workspace_id:)
|
|
73
|
+
http.request(:delete, "/ads/connections/#{connection_id}", params: { 'workspace_id' => workspace_id })
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Promote a post FoPost already published. Needs `ads` and `publish`.
|
|
78
|
+
# The boost starts paused unless `paused: false`.
|
|
79
|
+
def boost(workspace_id:, connection_id:, ad_account_id:, post_id:, account_id:, name:, goal:,
|
|
80
|
+
budget:, targeting:, paused: nil)
|
|
81
|
+
body = {
|
|
82
|
+
'workspaceId' => workspace_id,
|
|
83
|
+
'connectionId' => connection_id,
|
|
84
|
+
'adAccountId' => ad_account_id,
|
|
85
|
+
'postId' => post_id,
|
|
86
|
+
'accountId' => account_id,
|
|
87
|
+
'name' => name,
|
|
88
|
+
'goal' => goal,
|
|
89
|
+
'budget' => stringify(budget),
|
|
90
|
+
'targeting' => stringify(targeting)
|
|
91
|
+
}
|
|
92
|
+
body['paused'] = paused unless paused.nil?
|
|
93
|
+
Ad.new(unwrap(http.post('/ads/boost', body)))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Create a standalone ad from a creative. Needs `ads` and `publish`.
|
|
97
|
+
# The ad starts paused unless `paused: false`.
|
|
98
|
+
def create(workspace_id:, connection_id:, ad_account_id:, page_id:, name:, goal:, budget:, targeting:,
|
|
99
|
+
text:, headline: nil, destination_url: nil, media_url: nil, url_tags: nil,
|
|
100
|
+
spark_post_id: nil, paused: nil)
|
|
101
|
+
body = {
|
|
102
|
+
'workspaceId' => workspace_id,
|
|
103
|
+
'connectionId' => connection_id,
|
|
104
|
+
'adAccountId' => ad_account_id,
|
|
105
|
+
'pageId' => page_id,
|
|
106
|
+
'name' => name,
|
|
107
|
+
'goal' => goal,
|
|
108
|
+
'budget' => stringify(budget),
|
|
109
|
+
'targeting' => stringify(targeting),
|
|
110
|
+
'text' => text
|
|
111
|
+
}
|
|
112
|
+
body.merge!(
|
|
113
|
+
compact_nil(
|
|
114
|
+
'headline' => headline,
|
|
115
|
+
'destinationUrl' => destination_url,
|
|
116
|
+
'mediaUrl' => media_url,
|
|
117
|
+
'urlTags' => url_tags,
|
|
118
|
+
'sparkPostId' => spark_post_id,
|
|
119
|
+
'paused' => paused
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
Ad.new(unwrap(http.post('/ads', body)))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Read the delivery status and lifetime insights from Meta.
|
|
126
|
+
def refresh(ad_id, workspace_id:)
|
|
127
|
+
Ad.new(unwrap(http.request(:post, "/ads/#{ad_id}/refresh", params: { 'workspace_id' => workspace_id })))
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# `status` is "active" or "paused". Needs `ads` and `publish`.
|
|
131
|
+
def set_status(ad_id, workspace_id:, status:)
|
|
132
|
+
Ad.new(
|
|
133
|
+
unwrap(
|
|
134
|
+
http.request(:patch, "/ads/#{ad_id}", json: { 'status' => status },
|
|
135
|
+
params: { 'workspace_id' => workspace_id })
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# End delivery and delete the ad on Meta as well as here. Needs `ads` and `publish`.
|
|
141
|
+
def delete(ad_id, workspace_id:)
|
|
142
|
+
http.request(:delete, "/ads/#{ad_id}", params: { 'workspace_id' => workspace_id })
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def audiences(connection_id:, ad_account_id:, workspace_id: nil)
|
|
147
|
+
AudiencesResult.new(
|
|
148
|
+
unwrap(
|
|
149
|
+
http.get(
|
|
150
|
+
'/ads/audiences',
|
|
151
|
+
{ 'workspace_id' => workspace_id, 'connection_id' => connection_id, 'ad_account_id' => ad_account_id }
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# `spec` carries a `subtype` of CUSTOM, LOOKALIKE or WEBSITE. Answers `{ id, added }`.
|
|
158
|
+
def create_audience(workspace_id:, connection_id:, ad_account_id:, name:, spec:, description: nil)
|
|
159
|
+
body = {
|
|
160
|
+
'workspaceId' => workspace_id,
|
|
161
|
+
'connectionId' => connection_id,
|
|
162
|
+
'adAccountId' => ad_account_id,
|
|
163
|
+
'name' => name,
|
|
164
|
+
'spec' => stringify(spec)
|
|
165
|
+
}
|
|
166
|
+
body['description'] = description unless description.nil?
|
|
167
|
+
as_hash(unwrap(http.post('/ads/audiences', body)))
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Locations, interests, behaviours and income brackets as Meta names them.
|
|
171
|
+
# `type` is country, region, city, zip, metro, interest, behavior or income.
|
|
172
|
+
def search_targeting(connection_id:, type:, q: nil, workspace_id: nil)
|
|
173
|
+
parse_list(
|
|
174
|
+
TargetingOption,
|
|
175
|
+
unwrap(
|
|
176
|
+
http.get(
|
|
177
|
+
'/ads/targeting/search',
|
|
178
|
+
{ 'workspace_id' => workspace_id, 'connection_id' => connection_id, 'type' => type, 'q' => q }
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def lead_forms(workspace_id: nil)
|
|
185
|
+
parse_list(LeadFormSource, unwrap(http.get('/ads/lead-forms', { 'workspace_id' => workspace_id })))
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Create an Instant Form on the Page. Returns its id.
|
|
189
|
+
def create_lead_form(workspace_id:, connection_id:, page_id:, name:, questions:, privacy_policy_url:,
|
|
190
|
+
thank_you_message:, follow_up_url: nil)
|
|
191
|
+
body = {
|
|
192
|
+
'workspaceId' => workspace_id,
|
|
193
|
+
'connectionId' => connection_id,
|
|
194
|
+
'pageId' => page_id,
|
|
195
|
+
'name' => name,
|
|
196
|
+
'questions' => questions.to_a,
|
|
197
|
+
'privacyPolicyUrl' => privacy_policy_url,
|
|
198
|
+
'thankYouMessage' => thank_you_message
|
|
199
|
+
}
|
|
200
|
+
body['followUpUrl'] = follow_up_url unless follow_up_url.nil?
|
|
201
|
+
result = unwrap(http.post('/ads/lead-forms', body))
|
|
202
|
+
id = result.is_a?(Hash) ? result['id'] : nil
|
|
203
|
+
id.nil? ? '' : id.to_s
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# One page of leads; pass `next_cursor` back as `after:` for the next.
|
|
207
|
+
def leads(form_id, connection_id:, page_id:, after: nil, workspace_id: nil)
|
|
208
|
+
LeadsPage.new(
|
|
209
|
+
unwrap(
|
|
210
|
+
http.get(
|
|
211
|
+
"/ads/lead-forms/#{form_id}/leads",
|
|
212
|
+
{ 'workspace_id' => workspace_id, 'connection_id' => connection_id, 'page_id' => page_id,
|
|
213
|
+
'after' => after }
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Campaigns, ad sets and ads on one ad account, read live from Meta.
|
|
220
|
+
def account_tree(ad_account_id, connection_id:, workspace_id: nil)
|
|
221
|
+
AdAccountTree.new(
|
|
222
|
+
unwrap(http.get("/ads/accounts/#{ad_account_id}/tree", meta_query(workspace_id, connection_id)))
|
|
223
|
+
)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# `goal` is engagement, traffic, awareness or video_views. Starts paused
|
|
227
|
+
# unless `paused: false`. Needs `ads` and `publish`.
|
|
228
|
+
# TikTok's Business Centers, the one network-named read in this resource.
|
|
229
|
+
def tiktok_business_centers(connection_id:, workspace_id: nil)
|
|
230
|
+
parse_list(
|
|
231
|
+
AdBusinessCenter,
|
|
232
|
+
unwrap(
|
|
233
|
+
http.get(
|
|
234
|
+
'/ads/tiktok/business-centers',
|
|
235
|
+
{ 'workspace_id' => workspace_id, 'connection_id' => connection_id }
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# The accounts an ad can run as; an identity id is a page_id.
|
|
242
|
+
def tiktok_identities(connection_id:, ad_account_id:, workspace_id: nil)
|
|
243
|
+
parse_list(
|
|
244
|
+
AdIdentity,
|
|
245
|
+
unwrap(
|
|
246
|
+
http.get(
|
|
247
|
+
'/ads/tiktok/identities',
|
|
248
|
+
{ 'workspace_id' => workspace_id, 'connection_id' => connection_id,
|
|
249
|
+
'ad_account_id' => ad_account_id }
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Posts already live under an identity, each a candidate Spark ad.
|
|
256
|
+
def spark_posts(connection_id:, ad_account_id:, identity_id:, workspace_id: nil)
|
|
257
|
+
parse_list(
|
|
258
|
+
SparkPost,
|
|
259
|
+
unwrap(
|
|
260
|
+
http.get(
|
|
261
|
+
'/ads/spark-posts',
|
|
262
|
+
{ 'workspace_id' => workspace_id, 'connection_id' => connection_id,
|
|
263
|
+
'ad_account_id' => ad_account_id, 'identity_id' => identity_id }
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Offline conversions. Identifiers are hashed before they leave FoPost.
|
|
270
|
+
def upload_conversions(workspace_id:, connection_id:, ad_account_id:, pixel_id:, events:)
|
|
271
|
+
unwrap(
|
|
272
|
+
http.post(
|
|
273
|
+
'/ads/conversions',
|
|
274
|
+
{ 'workspaceId' => workspace_id, 'connectionId' => connection_id,
|
|
275
|
+
'adAccountId' => ad_account_id, 'pixelId' => pixel_id,
|
|
276
|
+
'events' => events.map { |e| stringify(e) } }
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# One page of an ad's comments; pass `next_cursor` back as `after`.
|
|
282
|
+
def comments(connection_id:, ad_id:, after: nil, workspace_id: nil)
|
|
283
|
+
AdCommentsPage.new(
|
|
284
|
+
unwrap(
|
|
285
|
+
http.get(
|
|
286
|
+
'/ads/comments',
|
|
287
|
+
{ 'workspace_id' => workspace_id, 'connection_id' => connection_id,
|
|
288
|
+
'ad_id' => ad_id, 'after' => after }
|
|
289
|
+
)
|
|
290
|
+
)
|
|
291
|
+
)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Needs the `publish` scope as well as `ads`.
|
|
295
|
+
def reply_to_comment(comment_id, workspace_id:, connection_id:, ad_id:, text:)
|
|
296
|
+
unwrap(
|
|
297
|
+
http.post(
|
|
298
|
+
"/ads/comments/#{comment_id}/reply",
|
|
299
|
+
{ 'workspaceId' => workspace_id, 'connectionId' => connection_id,
|
|
300
|
+
'adId' => ad_id, 'text' => text }
|
|
301
|
+
)
|
|
302
|
+
)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Needs the `publish` scope as well as `ads`.
|
|
306
|
+
def set_comment_hidden(comment_id, workspace_id:, connection_id:, ad_id:, hidden:)
|
|
307
|
+
http.post(
|
|
308
|
+
"/ads/comments/#{comment_id}/hide",
|
|
309
|
+
{ 'workspaceId' => workspace_id, 'connectionId' => connection_id,
|
|
310
|
+
'adId' => ad_id, 'hidden' => hidden }
|
|
311
|
+
)
|
|
312
|
+
nil
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# One already gone on the network succeeds. Needs `publish` as well as `ads`.
|
|
316
|
+
def delete_comment(comment_id, workspace_id:, connection_id:, ad_id:)
|
|
317
|
+
http.request(
|
|
318
|
+
:delete,
|
|
319
|
+
"/ads/comments/#{comment_id}",
|
|
320
|
+
json: { 'workspaceId' => workspace_id, 'connectionId' => connection_id, 'adId' => ad_id }
|
|
321
|
+
)
|
|
322
|
+
nil
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def create_campaign(workspace_id:, connection_id:, ad_account_id:, name:, goal:, paused: nil,
|
|
326
|
+
smart_plus: nil)
|
|
327
|
+
body = {
|
|
328
|
+
'workspaceId' => workspace_id,
|
|
329
|
+
'connectionId' => connection_id,
|
|
330
|
+
'adAccountId' => ad_account_id,
|
|
331
|
+
'name' => name,
|
|
332
|
+
'goal' => goal
|
|
333
|
+
}
|
|
334
|
+
body['paused'] = paused unless paused.nil?
|
|
335
|
+
body['smartPlus'] = smart_plus unless smart_plus.nil?
|
|
336
|
+
AdCampaign.new(unwrap(http.post('/ads/campaigns', body)))
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def get_campaign(campaign_id, connection_id:, workspace_id: nil)
|
|
340
|
+
AdCampaign.new(unwrap(http.get("/ads/campaigns/#{campaign_id}", meta_query(workspace_id, connection_id))))
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# `status` is "active" or "paused". Needs `ads` and `publish`.
|
|
344
|
+
def update_campaign(campaign_id, workspace_id:, connection_id:, name: nil, status: nil)
|
|
345
|
+
AdCampaign.new(
|
|
346
|
+
patch_object("/ads/campaigns/#{campaign_id}", workspace_id, connection_id,
|
|
347
|
+
'name' => name, 'status' => status)
|
|
348
|
+
)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Needs `ads` and `publish`.
|
|
352
|
+
def delete_campaign(campaign_id, workspace_id:, connection_id:)
|
|
353
|
+
delete_object("/ads/campaigns/#{campaign_id}", workspace_id, connection_id)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Copy the campaign on Meta and return the copy's id. Needs `ads` and `publish`.
|
|
357
|
+
def duplicate_campaign(campaign_id, workspace_id:, connection_id:, paused: nil)
|
|
358
|
+
duplicate_object("/ads/campaigns/#{campaign_id}", workspace_id, connection_id, paused)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# `budget` is `{ minor:, type:, endAt: }`; `targeting` uses the API's camelCase keys.
|
|
362
|
+
# Starts paused unless `paused: false`. Needs `ads` and `publish`.
|
|
363
|
+
def create_ad_set(workspace_id:, connection_id:, campaign_id:, page_id:, name:, goal:, budget:, targeting:,
|
|
364
|
+
paused: nil)
|
|
365
|
+
body = {
|
|
366
|
+
'workspaceId' => workspace_id,
|
|
367
|
+
'connectionId' => connection_id,
|
|
368
|
+
'campaignId' => campaign_id,
|
|
369
|
+
'pageId' => page_id,
|
|
370
|
+
'name' => name,
|
|
371
|
+
'goal' => goal,
|
|
372
|
+
'budget' => stringify(budget),
|
|
373
|
+
'targeting' => stringify(targeting)
|
|
374
|
+
}
|
|
375
|
+
body['paused'] = paused unless paused.nil?
|
|
376
|
+
AdSet.new(unwrap(http.post('/ads/ad-sets', body)))
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def get_ad_set(ad_set_id, connection_id:, workspace_id: nil)
|
|
380
|
+
AdSet.new(unwrap(http.get("/ads/ad-sets/#{ad_set_id}", meta_query(workspace_id, connection_id))))
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Needs `ads` and `publish`.
|
|
384
|
+
def update_ad_set(ad_set_id, workspace_id:, connection_id:, name: nil, status: nil, budget_minor: nil,
|
|
385
|
+
end_at: nil, targeting: nil)
|
|
386
|
+
AdSet.new(
|
|
387
|
+
patch_object(
|
|
388
|
+
"/ads/ad-sets/#{ad_set_id}", workspace_id, connection_id,
|
|
389
|
+
'name' => name, 'status' => status, 'budgetMinor' => budget_minor, 'endAt' => iso8601(end_at),
|
|
390
|
+
'targeting' => targeting.nil? ? nil : stringify(targeting)
|
|
391
|
+
)
|
|
392
|
+
)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Needs `ads` and `publish`.
|
|
396
|
+
def delete_ad_set(ad_set_id, workspace_id:, connection_id:)
|
|
397
|
+
delete_object("/ads/ad-sets/#{ad_set_id}", workspace_id, connection_id)
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Needs `ads` and `publish`.
|
|
401
|
+
def duplicate_ad_set(ad_set_id, workspace_id:, connection_id:, paused: nil)
|
|
402
|
+
duplicate_object("/ads/ad-sets/#{ad_set_id}", workspace_id, connection_id, paused)
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# An ad inside an ad set, from an existing creative (unlike {#create}).
|
|
406
|
+
# Starts paused unless `paused: false`. Needs `ads` and `publish`.
|
|
407
|
+
def create_network_ad(workspace_id:, connection_id:, ad_set_id:, creative_id:, name:, paused: nil)
|
|
408
|
+
body = {
|
|
409
|
+
'workspaceId' => workspace_id,
|
|
410
|
+
'connectionId' => connection_id,
|
|
411
|
+
'adSetId' => ad_set_id,
|
|
412
|
+
'creativeId' => creative_id,
|
|
413
|
+
'name' => name
|
|
414
|
+
}
|
|
415
|
+
body['paused'] = paused unless paused.nil?
|
|
416
|
+
NetworkAd.new(unwrap(http.post('/ads/ads', body)))
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def get_network_ad(ad_id, connection_id:, workspace_id: nil)
|
|
420
|
+
NetworkAd.new(unwrap(http.get("/ads/ads/#{ad_id}", meta_query(workspace_id, connection_id))))
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Needs `ads` and `publish`.
|
|
424
|
+
def update_network_ad(ad_id, workspace_id:, connection_id:, name: nil, status: nil, creative_id: nil)
|
|
425
|
+
NetworkAd.new(
|
|
426
|
+
patch_object("/ads/ads/#{ad_id}", workspace_id, connection_id,
|
|
427
|
+
'name' => name, 'status' => status, 'creativeId' => creative_id)
|
|
428
|
+
)
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# Needs `ads` and `publish`.
|
|
432
|
+
def delete_network_ad(ad_id, workspace_id:, connection_id:)
|
|
433
|
+
delete_object("/ads/ads/#{ad_id}", workspace_id, connection_id)
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Needs `ads` and `publish`.
|
|
437
|
+
def duplicate_network_ad(ad_id, workspace_id:, connection_id:, paused: nil)
|
|
438
|
+
duplicate_object("/ads/ads/#{ad_id}", workspace_id, connection_id, paused)
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# Pause or activate many objects at once. Each of `objects` is `{ id:, level: }`
|
|
442
|
+
# with level campaign, ad_set or ad. Needs `ads` and `publish`.
|
|
443
|
+
def bulk_set_status(workspace_id:, connection_id:, status:, objects:)
|
|
444
|
+
body = {
|
|
445
|
+
'workspaceId' => workspace_id,
|
|
446
|
+
'connectionId' => connection_id,
|
|
447
|
+
'status' => status,
|
|
448
|
+
'objects' => objects.to_a.map { |object| stringify(object) }
|
|
449
|
+
}
|
|
450
|
+
parse_list(BulkAdStatusResult, unwrap(http.post('/ads/status', body)))
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def creatives(connection_id:, ad_account_id:, workspace_id: nil)
|
|
454
|
+
result = unwrap(
|
|
455
|
+
http.get('/ads/creatives', meta_query(workspace_id, connection_id).merge('ad_account_id' => ad_account_id))
|
|
456
|
+
)
|
|
457
|
+
parse_list(AdCreative, result.is_a?(Hash) ? result['creatives'] : nil)
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# `format` is image, video or carousel; a carousel takes `cards`
|
|
461
|
+
# (`{ mediaUrl:, destinationUrl:, headline:, description: }`).
|
|
462
|
+
def create_creative(workspace_id:, connection_id:, ad_account_id:, page_id:, name:, format:, text:,
|
|
463
|
+
headline: nil, destination_url: nil, call_to_action: nil, url_tags: nil, media_url: nil,
|
|
464
|
+
thumbnail_media_url: nil, cards: nil)
|
|
465
|
+
body = {
|
|
466
|
+
'workspaceId' => workspace_id,
|
|
467
|
+
'connectionId' => connection_id,
|
|
468
|
+
'adAccountId' => ad_account_id,
|
|
469
|
+
'pageId' => page_id,
|
|
470
|
+
'name' => name,
|
|
471
|
+
'format' => format,
|
|
472
|
+
'text' => text
|
|
473
|
+
}
|
|
474
|
+
body.merge!(
|
|
475
|
+
compact_nil(
|
|
476
|
+
'headline' => headline,
|
|
477
|
+
'destinationUrl' => destination_url,
|
|
478
|
+
'callToAction' => call_to_action,
|
|
479
|
+
'urlTags' => url_tags,
|
|
480
|
+
'mediaUrl' => media_url,
|
|
481
|
+
'thumbnailMediaUrl' => thumbnail_media_url,
|
|
482
|
+
'cards' => cards&.map { |card| stringify(card) }
|
|
483
|
+
)
|
|
484
|
+
)
|
|
485
|
+
AdCreative.new(unwrap(http.post('/ads/creatives', body)))
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def get_creative(creative_id, connection_id:, workspace_id: nil)
|
|
489
|
+
AdCreative.new(unwrap(http.get("/ads/creatives/#{creative_id}", meta_query(workspace_id, connection_id))))
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
def delete_creative(creative_id, workspace_id:, connection_id:)
|
|
493
|
+
delete_object("/ads/creatives/#{creative_id}", workspace_id, connection_id)
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def get_audience(audience_id, connection_id:, workspace_id: nil)
|
|
497
|
+
Audience.new(unwrap(http.get("/ads/audiences/#{audience_id}", meta_query(workspace_id, connection_id))))
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def update_audience(audience_id, workspace_id:, connection_id:, name: nil, description: nil)
|
|
501
|
+
Audience.new(
|
|
502
|
+
patch_object("/ads/audiences/#{audience_id}", workspace_id, connection_id,
|
|
503
|
+
'name' => name, 'description' => description)
|
|
504
|
+
)
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def delete_audience(audience_id, workspace_id:, connection_id:)
|
|
508
|
+
delete_object("/ads/audiences/#{audience_id}", workspace_id, connection_id)
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# Add customers to a custom audience by email. Returns how many were sent to Meta.
|
|
512
|
+
def add_audience_users(audience_id, workspace_id:, connection_id:, emails:)
|
|
513
|
+
result = unwrap(
|
|
514
|
+
http.request(:post, "/ads/audiences/#{audience_id}/users",
|
|
515
|
+
json: { 'emails' => emails.to_a }, params: meta_query(workspace_id, connection_id))
|
|
516
|
+
)
|
|
517
|
+
result.is_a?(Hash) ? result['added'].to_i : 0
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# Add companies to a company-list audience. Answers the count the network took.
|
|
521
|
+
# Each row needs a name, domain, pageUrl or ticker; the rows are never stored.
|
|
522
|
+
def add_audience_companies(audience_id, workspace_id:, connection_id:, companies:)
|
|
523
|
+
result = unwrap(
|
|
524
|
+
http.request(:post, "/ads/audiences/#{audience_id}/companies",
|
|
525
|
+
json: { 'companies' => companies.map { |c| stringify(c) } },
|
|
526
|
+
params: meta_query(workspace_id, connection_id))
|
|
527
|
+
)
|
|
528
|
+
result.is_a?(Hash) ? result['added'].to_i : 0
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# What the auction currently costs for that audience.
|
|
532
|
+
def bid_pricing(workspace_id:, connection_id:, ad_account_id:, goal:, targeting:,
|
|
533
|
+
placements: nil, bid_type: nil)
|
|
534
|
+
body = forecast_body(workspace_id, connection_id, ad_account_id, goal, targeting, placements)
|
|
535
|
+
body['bidType'] = bid_type unless bid_type.nil?
|
|
536
|
+
BidPricing.new(unwrap(http.post('/ads/linkedin/bid-pricing', body)))
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
# What that audience would deliver at that budget.
|
|
540
|
+
def supply_forecast(workspace_id:, connection_id:, ad_account_id:, goal:, targeting:,
|
|
541
|
+
placements: nil, budget_minor: nil)
|
|
542
|
+
body = forecast_body(workspace_id, connection_id, ad_account_id, goal, targeting, placements)
|
|
543
|
+
body['budgetMinor'] = budget_minor unless budget_minor.nil?
|
|
544
|
+
SupplyForecast.new(unwrap(http.post('/ads/linkedin/supply-forecast', body)))
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def conversion_rules(connection_id:, ad_account_id:, workspace_id: nil)
|
|
548
|
+
query = meta_query(workspace_id, connection_id).merge('ad_account_id' => ad_account_id)
|
|
549
|
+
parse_list(ConversionRule, unwrap(http.get('/ads/linkedin/conversion-rules', query)))
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
# Answers the new rule's id.
|
|
553
|
+
def create_conversion_rule(workspace_id:, connection_id:, ad_account_id:, name:, type:, attribution:,
|
|
554
|
+
post_click_window_days: nil, view_through_window_days: nil,
|
|
555
|
+
value_minor: nil, currency: nil)
|
|
556
|
+
body = compact_nil(
|
|
557
|
+
'workspaceId' => workspace_id, 'connectionId' => connection_id, 'adAccountId' => ad_account_id,
|
|
558
|
+
'name' => name, 'type' => type, 'attribution' => attribution,
|
|
559
|
+
'postClickWindowDays' => post_click_window_days,
|
|
560
|
+
'viewThroughWindowDays' => view_through_window_days,
|
|
561
|
+
'valueMinor' => value_minor, 'currency' => currency
|
|
562
|
+
)
|
|
563
|
+
result = unwrap(http.post('/ads/linkedin/conversion-rules', body))
|
|
564
|
+
id = result.is_a?(Hash) ? result['id'] : nil
|
|
565
|
+
id.nil? ? '' : id.to_s
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
def get_conversion_rule(rule_id, connection_id:, workspace_id: nil)
|
|
569
|
+
ConversionRule.new(
|
|
570
|
+
unwrap(http.get(rule_path(rule_id), meta_query(workspace_id, connection_id)))
|
|
571
|
+
)
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
# Keys are the API's own: name, type, attribution, postClickWindowDays,
|
|
575
|
+
# viewThroughWindowDays, valueMinor, currency, enabled.
|
|
576
|
+
def update_conversion_rule(rule_id, workspace_id:, connection_id:, **changes)
|
|
577
|
+
ConversionRule.new(patch_object(rule_path(rule_id), workspace_id, connection_id, stringify(changes)))
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
# Turns the rule off; the network keeps the history.
|
|
581
|
+
def delete_conversion_rule(rule_id, workspace_id:, connection_id:)
|
|
582
|
+
delete_object(rule_path(rule_id), workspace_id, connection_id)
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
def attach_conversion_rule(rule_id, workspace_id:, connection_id:, campaign_id:)
|
|
586
|
+
association(:post, rule_id, workspace_id, connection_id, campaign_id)
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
def detach_conversion_rule(rule_id, workspace_id:, connection_id:, campaign_id:)
|
|
590
|
+
association(:delete, rule_id, workspace_id, connection_id, campaign_id)
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
# What the rule recorded between two YYYY-MM-DD days, inclusive.
|
|
594
|
+
def conversion_metrics(rule_id, connection_id:, since:, until:, workspace_id: nil)
|
|
595
|
+
until_date = binding.local_variable_get(:until)
|
|
596
|
+
query = meta_query(workspace_id, connection_id)
|
|
597
|
+
.merge('since' => since.to_s, 'until' => until_date.to_s)
|
|
598
|
+
ConversionMetrics.new(unwrap(http.get("#{rule_path(rule_id)}/metrics", query)))
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# Send conversions back to the network. Answers how many it took. Each event
|
|
602
|
+
# needs happenedAt in epoch milliseconds and an email or a clickId; the address
|
|
603
|
+
# is hashed inside the API and nothing about an event is stored.
|
|
604
|
+
def send_conversion_events(rule_id, workspace_id:, connection_id:, events:)
|
|
605
|
+
result = unwrap(
|
|
606
|
+
http.request(:post, "#{rule_path(rule_id)}/events",
|
|
607
|
+
json: { 'events' => events.map { |e| stringify(e) } },
|
|
608
|
+
params: meta_query(workspace_id, connection_id))
|
|
609
|
+
)
|
|
610
|
+
result.is_a?(Hash) ? result['accepted'].to_i : 0
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
def estimate_reach(workspace_id:, connection_id:, ad_account_id:, page_id:, targeting:)
|
|
614
|
+
body = {
|
|
615
|
+
'workspaceId' => workspace_id,
|
|
616
|
+
'connectionId' => connection_id,
|
|
617
|
+
'adAccountId' => ad_account_id,
|
|
618
|
+
'pageId' => page_id,
|
|
619
|
+
'targeting' => stringify(targeting)
|
|
620
|
+
}
|
|
621
|
+
ReachEstimate.new(unwrap(http.post('/ads/reach-estimate', body)))
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
# Insights for any campaign, ad set or ad id on Meta. `breakdown` is age,
|
|
625
|
+
# gender, placement or country; `daily: true` adds a per-day timeline.
|
|
626
|
+
def insights(connection_id:, object_id:, since:, until:, breakdown: nil, daily: nil, workspace_id: nil)
|
|
627
|
+
until_date = binding.local_variable_get(:until)
|
|
628
|
+
params = meta_query(workspace_id, connection_id).merge(
|
|
629
|
+
'object_id' => object_id, **insights_query(since, until_date, breakdown, daily)
|
|
630
|
+
)
|
|
631
|
+
AdInsightsReport.new(unwrap(http.get('/ads/insights', params)))
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
# Insights for an ad created through FoPost, by its FoPost id.
|
|
635
|
+
def ad_insights(ad_id, workspace_id:, since:, until:, breakdown: nil, daily: nil)
|
|
636
|
+
until_date = binding.local_variable_get(:until)
|
|
637
|
+
params = { 'workspace_id' => workspace_id, **insights_query(since, until_date, breakdown, daily) }
|
|
638
|
+
AdInsightsReport.new(unwrap(http.get("/ads/#{ad_id}/insights", params)))
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
def get_lead_form(form_id, connection_id:, page_id:, workspace_id: nil)
|
|
642
|
+
LeadFormDetail.new(
|
|
643
|
+
unwrap(
|
|
644
|
+
http.get("/ads/lead-forms/#{form_id}",
|
|
645
|
+
meta_query(workspace_id, connection_id).merge('page_id' => page_id))
|
|
646
|
+
)
|
|
647
|
+
)
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
def archive_lead_form(form_id, workspace_id:, connection_id:, page_id:)
|
|
651
|
+
body = { 'workspaceId' => workspace_id, 'connectionId' => connection_id, 'pageId' => page_id }
|
|
652
|
+
LeadFormDetail.new(unwrap(http.post("/ads/lead-forms/#{form_id}/archive", body)))
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
# Stored leads from subscribed Pages, newest first. Pass `next_cursor`
|
|
656
|
+
# back as `cursor:` for the next page.
|
|
657
|
+
def leads_feed(form_id: nil, page_id: nil, cursor: nil, limit: nil, workspace_id: nil)
|
|
658
|
+
LeadsFeedPage.new(
|
|
659
|
+
unwrap(
|
|
660
|
+
http.get(
|
|
661
|
+
'/ads/leads',
|
|
662
|
+
{ 'workspace_id' => workspace_id, 'form_id' => form_id, 'page_id' => page_id, 'cursor' => cursor,
|
|
663
|
+
'limit' => limit }
|
|
664
|
+
)
|
|
665
|
+
)
|
|
666
|
+
)
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
# Pages subscribed to lead delivery.
|
|
670
|
+
def lead_pages(workspace_id: nil)
|
|
671
|
+
parse_list(LeadPage, unwrap(http.get('/ads/lead-pages', { 'workspace_id' => workspace_id })))
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
# Subscribe a Page to lead delivery. Answers `{ "pageId", "backfilled" }`.
|
|
675
|
+
def subscribe_lead_page(workspace_id:, connection_id:, page_id:)
|
|
676
|
+
body = { 'workspaceId' => workspace_id, 'connectionId' => connection_id, 'pageId' => page_id }
|
|
677
|
+
as_hash(unwrap(http.post('/ads/lead-pages', body)))
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
def unsubscribe_lead_page(page_id, workspace_id:, connection_id:)
|
|
681
|
+
delete_object("/ads/lead-pages/#{page_id}", workspace_id, connection_id)
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
# ─── Goals ──────────────────────────────────────────────────
|
|
685
|
+
|
|
686
|
+
# The goals this connection's network can run right now. Ask rather than
|
|
687
|
+
# assume: a goal the deployment is not set up for is absent here and is
|
|
688
|
+
# refused if you send it anyway.
|
|
689
|
+
def goals(connection_id:, workspace_id: nil)
|
|
690
|
+
result = unwrap(http.get('/ads/goals', meta_query(workspace_id, connection_id)))
|
|
691
|
+
result.is_a?(Array) ? result.map(&:to_s) : []
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
# ─── Product catalogs ───────────────────────────────────────
|
|
695
|
+
|
|
696
|
+
# Catalogs the connection's business portfolios reach. Read live, never stored.
|
|
697
|
+
def catalogs(connection_id:, workspace_id: nil)
|
|
698
|
+
ProductCatalogsResult.from(unwrap(http.get('/ads/catalogs', meta_query(workspace_id, connection_id))))
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
# Created on the connection's business portfolio. Also needs `publish`.
|
|
702
|
+
def create_catalog(workspace_id:, connection_id:, name:, vertical: nil)
|
|
703
|
+
body = compact_nil(
|
|
704
|
+
'workspaceId' => workspace_id, 'connectionId' => connection_id,
|
|
705
|
+
'name' => name, 'vertical' => vertical
|
|
706
|
+
)
|
|
707
|
+
ProductCatalog.from(unwrap(http.post('/ads/catalogs', body)))
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
def catalog(catalog_id, connection_id:, workspace_id: nil)
|
|
711
|
+
ProductCatalog.from(
|
|
712
|
+
unwrap(http.get("/ads/catalogs/#{catalog_id}", meta_query(workspace_id, connection_id)))
|
|
713
|
+
)
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
# Also needs `publish`.
|
|
717
|
+
def update_catalog(catalog_id, workspace_id:, connection_id:, name:)
|
|
718
|
+
ProductCatalog.from(patch_object(
|
|
719
|
+
"/ads/catalogs/#{catalog_id}", workspace_id, connection_id,
|
|
720
|
+
'workspaceId' => workspace_id, 'connectionId' => connection_id, 'name' => name
|
|
721
|
+
))
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
# Deletes every product, feed and set in it. Also needs `publish`.
|
|
725
|
+
def delete_catalog(catalog_id, workspace_id:, connection_id:)
|
|
726
|
+
delete_object("/ads/catalogs/#{catalog_id}", workspace_id, connection_id)
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
# One page of products; pass `next_cursor` back as `after`.
|
|
730
|
+
def catalog_products(catalog_id, connection_id:, workspace_id: nil, after: nil)
|
|
731
|
+
query = meta_query(workspace_id, connection_id).merge('after' => after)
|
|
732
|
+
CatalogProductsPage.from(unwrap(http.get("/ads/catalogs/#{catalog_id}/products", query)))
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
# Up to 500 upserts and deletes in one batch, keyed by your own `retailerId`.
|
|
736
|
+
# Also needs `publish`.
|
|
737
|
+
def write_catalog_products(catalog_id, workspace_id:, connection_id:, products:)
|
|
738
|
+
body = {
|
|
739
|
+
'workspaceId' => workspace_id, 'connectionId' => connection_id,
|
|
740
|
+
'products' => products.map { |p| stringify(p) }
|
|
741
|
+
}
|
|
742
|
+
CatalogBatchResult.from(unwrap(http.post("/ads/catalogs/#{catalog_id}/products", body)))
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
def product_feeds(catalog_id, connection_id:, workspace_id: nil)
|
|
746
|
+
parse_list(
|
|
747
|
+
ProductFeed,
|
|
748
|
+
unwrap(http.get("/ads/catalogs/#{catalog_id}/feeds", meta_query(workspace_id, connection_id)))
|
|
749
|
+
)
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
# `schedule` is "HOURLY", "DAILY" or "WEEKLY" and needs `url`. Also needs `publish`.
|
|
753
|
+
def create_product_feed(catalog_id, workspace_id:, connection_id:, name:, url: nil, schedule: nil)
|
|
754
|
+
body = compact_nil(
|
|
755
|
+
'workspaceId' => workspace_id, 'connectionId' => connection_id,
|
|
756
|
+
'name' => name, 'url' => url, 'schedule' => schedule
|
|
757
|
+
)
|
|
758
|
+
ProductFeed.from(unwrap(http.post("/ads/catalogs/#{catalog_id}/feeds", body)))
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
# Also needs `publish`.
|
|
762
|
+
def delete_product_feed(catalog_id, feed_id, workspace_id:, connection_id:)
|
|
763
|
+
delete_object("/ads/catalogs/#{catalog_id}/feeds/#{feed_id}", workspace_id, connection_id)
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
# Each run the network made of the feed.
|
|
767
|
+
def feed_uploads(catalog_id, feed_id, connection_id:, workspace_id: nil)
|
|
768
|
+
path = "/ads/catalogs/#{catalog_id}/feeds/#{feed_id}/uploads"
|
|
769
|
+
parse_list(ProductFeedUpload, unwrap(http.get(path, meta_query(workspace_id, connection_id))))
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
# Fetches the feed now; the id of the run. Also needs `publish`.
|
|
773
|
+
def start_feed_upload(catalog_id, feed_id, workspace_id:, connection_id:, url: nil)
|
|
774
|
+
body = compact_nil('workspaceId' => workspace_id, 'connectionId' => connection_id, 'url' => url)
|
|
775
|
+
result = unwrap(http.post("/ads/catalogs/#{catalog_id}/feeds/#{feed_id}/uploads", body))
|
|
776
|
+
id = result.is_a?(Hash) ? result['id'] : nil
|
|
777
|
+
id.nil? ? '' : id.to_s
|
|
778
|
+
end
|
|
779
|
+
|
|
780
|
+
# A catalog ad runs from a product set, not the whole catalog.
|
|
781
|
+
def product_sets(catalog_id, connection_id:, workspace_id: nil)
|
|
782
|
+
parse_list(ProductSet, unwrap(http.get(
|
|
783
|
+
"/ads/catalogs/#{catalog_id}/product-sets", meta_query(workspace_id,
|
|
784
|
+
connection_id)
|
|
785
|
+
)))
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
# Without a `filter` the set is the whole catalog. Also needs `publish`.
|
|
789
|
+
def create_product_set(catalog_id, workspace_id:, connection_id:, name:, filter: nil)
|
|
790
|
+
body = compact_nil(
|
|
791
|
+
'workspaceId' => workspace_id, 'connectionId' => connection_id,
|
|
792
|
+
'name' => name, 'filter' => filter.nil? ? nil : stringify(filter)
|
|
793
|
+
)
|
|
794
|
+
ProductSet.from(unwrap(http.post("/ads/catalogs/#{catalog_id}/product-sets", body)))
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
# Also needs `publish`.
|
|
798
|
+
def update_product_set(catalog_id, set_id, workspace_id:, connection_id:, name:, filter: nil)
|
|
799
|
+
ProductSet.from(patch_object(
|
|
800
|
+
"/ads/catalogs/#{catalog_id}/product-sets/#{set_id}", workspace_id, connection_id,
|
|
801
|
+
'workspaceId' => workspace_id, 'connectionId' => connection_id,
|
|
802
|
+
'name' => name, 'filter' => filter.nil? ? nil : stringify(filter)
|
|
803
|
+
))
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
# Also needs `publish`.
|
|
807
|
+
def delete_product_set(catalog_id, set_id, workspace_id:, connection_id:)
|
|
808
|
+
delete_object("/ads/catalogs/#{catalog_id}/product-sets/#{set_id}", workspace_id, connection_id)
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
# ─── Reach and frequency ────────────────────────────────────
|
|
812
|
+
|
|
813
|
+
def reach_frequency(connection_id:, ad_account_id:, workspace_id: nil)
|
|
814
|
+
query = account_query(workspace_id, connection_id, ad_account_id)
|
|
815
|
+
ReachFrequencyResult.from(unwrap(http.get('/ads/reach-frequency', query)))
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
# Prices a flight. Nothing is bought until you reserve it.
|
|
819
|
+
def create_reach_frequency(workspace_id:, connection_id:, ad_account_id:, name:, targeting:,
|
|
820
|
+
placements:, budget_minor:, start_at:, end_at:, frequency_cap: nil)
|
|
821
|
+
body = compact_nil(
|
|
822
|
+
'workspaceId' => workspace_id, 'connectionId' => connection_id, 'adAccountId' => ad_account_id,
|
|
823
|
+
'name' => name, 'targeting' => stringify(targeting), 'placements' => placements.map(&:to_s),
|
|
824
|
+
'budgetMinor' => budget_minor, 'startAt' => start_at.to_s, 'endAt' => end_at.to_s,
|
|
825
|
+
'frequencyCap' => frequency_cap
|
|
826
|
+
)
|
|
827
|
+
ReachFrequencyPrediction.from(unwrap(http.post('/ads/reach-frequency', body)))
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
def reach_frequency_prediction(prediction_id, connection_id:, ad_account_id:, workspace_id: nil)
|
|
831
|
+
query = account_query(workspace_id, connection_id, ad_account_id)
|
|
832
|
+
ReachFrequencyPrediction.from(unwrap(http.get("/ads/reach-frequency/#{prediction_id}", query)))
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
# Holds the inventory the prediction priced. Also needs `publish`.
|
|
836
|
+
def reserve_reach_frequency(prediction_id, workspace_id:, connection_id:, ad_account_id:)
|
|
837
|
+
reach_frequency_action(prediction_id, 'reserve', workspace_id, connection_id, ad_account_id)
|
|
838
|
+
end
|
|
839
|
+
|
|
840
|
+
# Also needs `publish`.
|
|
841
|
+
def cancel_reach_frequency(prediction_id, workspace_id:, connection_id:, ad_account_id:)
|
|
842
|
+
reach_frequency_action(prediction_id, 'cancel', workspace_id, connection_id, ad_account_id)
|
|
843
|
+
end
|
|
844
|
+
|
|
845
|
+
# ─── Ad Library ─────────────────────────────────────────────
|
|
846
|
+
|
|
847
|
+
# The public ad archive: ads anyone is running, by keyword or by Page. Read
|
|
848
|
+
# live on every call and stored nowhere, so an ad that stops running is
|
|
849
|
+
# simply absent from the next search.
|
|
850
|
+
def library(connection_id:, countries:, workspace_id: nil, q: nil, page_ids: nil,
|
|
851
|
+
active_status: nil, limit: nil, after: nil)
|
|
852
|
+
query = meta_query(workspace_id, connection_id).merge(
|
|
853
|
+
'countries' => Array(countries).join(','),
|
|
854
|
+
'q' => q,
|
|
855
|
+
'page_ids' => page_ids.nil? ? nil : Array(page_ids).join(','),
|
|
856
|
+
'active_status' => active_status,
|
|
857
|
+
'limit' => limit&.to_s,
|
|
858
|
+
'after' => after
|
|
859
|
+
)
|
|
860
|
+
AdLibraryPage.from(unwrap(http.get('/ads/library', query)))
|
|
861
|
+
end
|
|
862
|
+
|
|
863
|
+
# ─── Partnership ads ────────────────────────────────────────
|
|
864
|
+
|
|
865
|
+
# Creators who allowlisted this Page to run partnership ads on their posts.
|
|
866
|
+
def partnership_creators(connection_id:, page_id:, workspace_id: nil)
|
|
867
|
+
query = meta_query(workspace_id, connection_id).merge('page_id' => page_id)
|
|
868
|
+
parse_list(PartnershipCreator, unwrap(http.get('/ads/partnership/creators', query)))
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
# Asks a creator for permission; the list as it now stands.
|
|
872
|
+
def request_partnership(workspace_id:, connection_id:, page_id:, creator_id:)
|
|
873
|
+
body = {
|
|
874
|
+
'workspaceId' => workspace_id, 'connectionId' => connection_id,
|
|
875
|
+
'pageId' => page_id, 'creatorId' => creator_id
|
|
876
|
+
}
|
|
877
|
+
parse_list(PartnershipCreator, unwrap(http.post('/ads/partnership/creators', body)))
|
|
878
|
+
end
|
|
879
|
+
|
|
880
|
+
def revoke_partnership(creator_id, workspace_id:, connection_id:, page_id:)
|
|
881
|
+
query = meta_query(workspace_id, connection_id).merge('page_id' => page_id)
|
|
882
|
+
http.request(:delete, "/ads/partnership/creators/#{creator_id}", params: query)
|
|
883
|
+
nil
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
# ─── Ad account settings ────────────────────────────────────
|
|
887
|
+
|
|
888
|
+
# Who changed what on the ad account, and when. Dates are "YYYY-MM-DD".
|
|
889
|
+
def account_activity(connection_id:, ad_account_id:, workspace_id: nil, since: nil, until_date: nil)
|
|
890
|
+
query = account_query(workspace_id, connection_id, ad_account_id)
|
|
891
|
+
.merge('since' => since&.to_s, 'until' => until_date&.to_s)
|
|
892
|
+
AdActivityResult.from(unwrap(http.get('/ads/account/activity', query)))
|
|
893
|
+
end
|
|
894
|
+
|
|
895
|
+
def labels(connection_id:, ad_account_id:, workspace_id: nil)
|
|
896
|
+
parse_list(AdLabel, unwrap(http.get(
|
|
897
|
+
'/ads/account/labels', account_query(workspace_id, connection_id, ad_account_id)
|
|
898
|
+
)))
|
|
899
|
+
end
|
|
900
|
+
|
|
901
|
+
def create_label(workspace_id:, connection_id:, ad_account_id:, name:)
|
|
902
|
+
body = {
|
|
903
|
+
'workspaceId' => workspace_id, 'connectionId' => connection_id,
|
|
904
|
+
'adAccountId' => ad_account_id, 'name' => name
|
|
905
|
+
}
|
|
906
|
+
AdLabel.from(unwrap(http.post('/ads/account/labels', body)))
|
|
907
|
+
end
|
|
908
|
+
|
|
909
|
+
def update_label(label_id, workspace_id:, connection_id:, ad_account_id:, name:)
|
|
910
|
+
AdLabel.from(patch_object(
|
|
911
|
+
"/ads/account/labels/#{label_id}", workspace_id, connection_id,
|
|
912
|
+
'workspaceId' => workspace_id, 'connectionId' => connection_id,
|
|
913
|
+
'adAccountId' => ad_account_id, 'name' => name
|
|
914
|
+
))
|
|
915
|
+
end
|
|
916
|
+
|
|
917
|
+
def delete_label(label_id, workspace_id:, connection_id:, ad_account_id:)
|
|
918
|
+
http.request(
|
|
919
|
+
:delete, "/ads/account/labels/#{label_id}",
|
|
920
|
+
params: account_query(workspace_id, connection_id, ad_account_id)
|
|
921
|
+
)
|
|
922
|
+
nil
|
|
923
|
+
end
|
|
924
|
+
|
|
925
|
+
# Keeps whatever labels the object already carries. `level` is
|
|
926
|
+
# "campaign", "ad_set" or "ad".
|
|
927
|
+
def apply_label(label_id, workspace_id:, connection_id:, ad_account_id:, object_id:, level:)
|
|
928
|
+
http.post("/ads/account/labels/#{label_id}/apply", {
|
|
929
|
+
'workspaceId' => workspace_id, 'connectionId' => connection_id,
|
|
930
|
+
'adAccountId' => ad_account_id, 'objectId' => object_id, 'level' => level
|
|
931
|
+
})
|
|
932
|
+
nil
|
|
933
|
+
end
|
|
934
|
+
|
|
935
|
+
def studies(connection_id:, ad_account_id:, workspace_id: nil)
|
|
936
|
+
parse_list(AdStudy, unwrap(http.get(
|
|
937
|
+
'/ads/account/studies', account_query(workspace_id, connection_id, ad_account_id)
|
|
938
|
+
)))
|
|
939
|
+
end
|
|
940
|
+
|
|
941
|
+
# Splits traffic evenly across two to five `cells` of "name" and "objectIds".
|
|
942
|
+
def create_study(workspace_id:, connection_id:, ad_account_id:, name:, start_at:, end_at:,
|
|
943
|
+
cells:, description: nil)
|
|
944
|
+
body = compact_nil(
|
|
945
|
+
'workspaceId' => workspace_id, 'connectionId' => connection_id, 'adAccountId' => ad_account_id,
|
|
946
|
+
'name' => name, 'startAt' => start_at.to_s, 'endAt' => end_at.to_s,
|
|
947
|
+
'cells' => cells.map { |c| stringify(c) }, 'description' => description
|
|
948
|
+
)
|
|
949
|
+
AdStudy.from(unwrap(http.post('/ads/account/studies', body)))
|
|
950
|
+
end
|
|
951
|
+
|
|
952
|
+
def study(study_id, connection_id:, ad_account_id:, workspace_id: nil)
|
|
953
|
+
AdStudy.from(unwrap(http.get(
|
|
954
|
+
"/ads/account/studies/#{study_id}", account_query(workspace_id, connection_id,
|
|
955
|
+
ad_account_id)
|
|
956
|
+
)))
|
|
957
|
+
end
|
|
958
|
+
|
|
959
|
+
def delete_study(study_id, workspace_id:, connection_id:, ad_account_id:)
|
|
960
|
+
http.request(
|
|
961
|
+
:delete, "/ads/account/studies/#{study_id}",
|
|
962
|
+
params: account_query(workspace_id, connection_id, ad_account_id)
|
|
963
|
+
)
|
|
964
|
+
nil
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
# How many iOS 14 campaigns the account may run at once, per app.
|
|
968
|
+
def ios_campaign_limits(connection_id:, ad_account_id:, workspace_id: nil)
|
|
969
|
+
parse_list(IosCampaignLimits, unwrap(http.get(
|
|
970
|
+
'/ads/account/ios-limits', account_query(workspace_id, connection_id,
|
|
971
|
+
ad_account_id)
|
|
972
|
+
)))
|
|
973
|
+
end
|
|
974
|
+
|
|
975
|
+
def high_demand_periods(connection_id:, ad_account_id:, workspace_id: nil)
|
|
976
|
+
query = account_query(workspace_id, connection_id, ad_account_id)
|
|
977
|
+
parse_list(HighDemandPeriod, unwrap(http.get('/ads/account/high-demand-periods', query)))
|
|
978
|
+
end
|
|
979
|
+
|
|
980
|
+
# Tells the network to expect heavier spend over a window, so pacing allows
|
|
981
|
+
# for it. `budget_value_type` is "ABSOLUTE" or "MULTIPLIER".
|
|
982
|
+
def create_high_demand_period(workspace_id:, connection_id:, ad_account_id:, start_at:, end_at:,
|
|
983
|
+
budget_value:, budget_value_type:)
|
|
984
|
+
body = {
|
|
985
|
+
'workspaceId' => workspace_id, 'connectionId' => connection_id, 'adAccountId' => ad_account_id,
|
|
986
|
+
'startAt' => start_at.to_s, 'endAt' => end_at.to_s,
|
|
987
|
+
'budgetValue' => budget_value, 'budgetValueType' => budget_value_type
|
|
988
|
+
}
|
|
989
|
+
HighDemandPeriod.from(unwrap(http.post('/ads/account/high-demand-periods', body)))
|
|
990
|
+
end
|
|
991
|
+
|
|
992
|
+
def delete_high_demand_period(period_id, workspace_id:, connection_id:, ad_account_id:)
|
|
993
|
+
http.request(
|
|
994
|
+
:delete, "/ads/account/high-demand-periods/#{period_id}",
|
|
995
|
+
params: account_query(workspace_id, connection_id, ad_account_id)
|
|
996
|
+
)
|
|
997
|
+
nil
|
|
998
|
+
end
|
|
999
|
+
|
|
1000
|
+
def value_rule_sets(connection_id:, ad_account_id:, workspace_id: nil)
|
|
1001
|
+
parse_list(ValueRuleSet, unwrap(http.get(
|
|
1002
|
+
'/ads/account/value-rule-sets', account_query(workspace_id, connection_id,
|
|
1003
|
+
ad_account_id)
|
|
1004
|
+
)))
|
|
1005
|
+
end
|
|
1006
|
+
|
|
1007
|
+
# Weights conversions so some audiences count for more than others.
|
|
1008
|
+
def create_value_rule_set(workspace_id:, connection_id:, ad_account_id:, name:, rules:)
|
|
1009
|
+
body = {
|
|
1010
|
+
'workspaceId' => workspace_id, 'connectionId' => connection_id, 'adAccountId' => ad_account_id,
|
|
1011
|
+
'name' => name, 'rules' => rules.map { |r| stringify(r) }
|
|
1012
|
+
}
|
|
1013
|
+
ValueRuleSet.from(unwrap(http.post('/ads/account/value-rule-sets', body)))
|
|
1014
|
+
end
|
|
1015
|
+
|
|
1016
|
+
def delete_value_rule_set(rule_set_id, workspace_id:, connection_id:, ad_account_id:)
|
|
1017
|
+
http.request(
|
|
1018
|
+
:delete, "/ads/account/value-rule-sets/#{rule_set_id}",
|
|
1019
|
+
params: account_query(workspace_id, connection_id, ad_account_id)
|
|
1020
|
+
)
|
|
1021
|
+
nil
|
|
1022
|
+
end
|
|
1023
|
+
|
|
1024
|
+
private
|
|
1025
|
+
|
|
1026
|
+
def account_query(workspace_id, connection_id, ad_account_id)
|
|
1027
|
+
meta_query(workspace_id, connection_id).merge('ad_account_id' => ad_account_id)
|
|
1028
|
+
end
|
|
1029
|
+
|
|
1030
|
+
def reach_frequency_action(prediction_id, action, workspace_id, connection_id, ad_account_id)
|
|
1031
|
+
body = {
|
|
1032
|
+
'workspaceId' => workspace_id, 'connectionId' => connection_id, 'adAccountId' => ad_account_id
|
|
1033
|
+
}
|
|
1034
|
+
ReachFrequencyPrediction.from(
|
|
1035
|
+
unwrap(http.post("/ads/reach-frequency/#{prediction_id}/#{action}", body))
|
|
1036
|
+
)
|
|
1037
|
+
end
|
|
1038
|
+
|
|
1039
|
+
def meta_query(workspace_id, connection_id)
|
|
1040
|
+
{ 'workspace_id' => workspace_id, 'connection_id' => connection_id }
|
|
1041
|
+
end
|
|
1042
|
+
|
|
1043
|
+
def rule_path(rule_id)
|
|
1044
|
+
"/ads/linkedin/conversion-rules/#{rule_id}"
|
|
1045
|
+
end
|
|
1046
|
+
|
|
1047
|
+
def forecast_body(workspace_id, connection_id, ad_account_id, goal, targeting, placements)
|
|
1048
|
+
body = {
|
|
1049
|
+
'workspaceId' => workspace_id,
|
|
1050
|
+
'connectionId' => connection_id,
|
|
1051
|
+
'adAccountId' => ad_account_id,
|
|
1052
|
+
'goal' => goal,
|
|
1053
|
+
'targeting' => stringify(targeting)
|
|
1054
|
+
}
|
|
1055
|
+
body['placements'] = placements.to_a unless placements.nil?
|
|
1056
|
+
body
|
|
1057
|
+
end
|
|
1058
|
+
|
|
1059
|
+
def association(method, rule_id, workspace_id, connection_id, campaign_id)
|
|
1060
|
+
ConversionRule.new(unwrap(
|
|
1061
|
+
http.request(method, "#{rule_path(rule_id)}/associations",
|
|
1062
|
+
json: { 'campaignId' => campaign_id },
|
|
1063
|
+
params: meta_query(workspace_id, connection_id))
|
|
1064
|
+
))
|
|
1065
|
+
end
|
|
1066
|
+
|
|
1067
|
+
def insights_query(since, until_date, breakdown, daily)
|
|
1068
|
+
query = { 'since' => since.to_s, 'until' => until_date.to_s, 'breakdown' => breakdown }
|
|
1069
|
+
query['daily'] = daily.to_s unless daily.nil?
|
|
1070
|
+
query
|
|
1071
|
+
end
|
|
1072
|
+
|
|
1073
|
+
def patch_object(path, workspace_id, connection_id, fields)
|
|
1074
|
+
unwrap(http.request(:patch, path, json: compact_nil(fields), params: meta_query(workspace_id, connection_id)))
|
|
1075
|
+
end
|
|
1076
|
+
|
|
1077
|
+
def delete_object(path, workspace_id, connection_id)
|
|
1078
|
+
http.request(:delete, path, params: meta_query(workspace_id, connection_id))
|
|
1079
|
+
nil
|
|
1080
|
+
end
|
|
1081
|
+
|
|
1082
|
+
# Answers the copy's Meta id.
|
|
1083
|
+
def duplicate_object(path, workspace_id, connection_id, paused)
|
|
1084
|
+
json = paused.nil? ? {} : { 'paused' => paused }
|
|
1085
|
+
result = unwrap(
|
|
1086
|
+
http.request(:post, "#{path}/duplicate", json: json, params: meta_query(workspace_id, connection_id))
|
|
1087
|
+
)
|
|
1088
|
+
id = result.is_a?(Hash) ? result['id'] : nil
|
|
1089
|
+
id.nil? ? '' : id.to_s
|
|
1090
|
+
end
|
|
1091
|
+
|
|
1092
|
+
def stringify(hash)
|
|
1093
|
+
hash.to_h.transform_keys(&:to_s)
|
|
1094
|
+
end
|
|
1095
|
+
end
|
|
1096
|
+
end
|
|
1097
|
+
end
|