adapi 0.0.3 → 0.0.4

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.
Files changed (41) hide show
  1. data/.gitignore +3 -0
  2. data/README.markdown +176 -0
  3. data/Rakefile +32 -0
  4. data/adapi.gemspec +7 -4
  5. data/examples/add_ad_group.rb +1 -0
  6. data/examples/add_bare_ad_group.rb +2 -6
  7. data/examples/add_bare_campaign.rb +1 -0
  8. data/examples/add_campaign.rb +1 -0
  9. data/examples/add_campaign_targets.rb +1 -0
  10. data/examples/add_invalid_ad_group.rb +1 -0
  11. data/examples/add_keywords.rb +13 -1
  12. data/examples/custom_settings.yml +2 -2
  13. data/examples/customize_configuration.rb +2 -0
  14. data/examples/delete_keyword.rb +27 -0
  15. data/examples/find_all_campaigns.rb +13 -0
  16. data/examples/find_campaign.rb +39 -0
  17. data/examples/find_campaign_ad_groups.rb +25 -0
  18. data/examples/log_to_specific_account.rb +1 -0
  19. data/examples/rollback_campaign.rb +1 -0
  20. data/lib/adapi.rb +8 -5
  21. data/lib/adapi/ad.rb +2 -0
  22. data/lib/adapi/ad/text_ad.rb +15 -0
  23. data/lib/adapi/ad_group.rb +45 -6
  24. data/lib/adapi/ad_group_criterion.rb +14 -0
  25. data/lib/adapi/api.rb +6 -0
  26. data/lib/adapi/campaign.rb +48 -5
  27. data/lib/adapi/campaign_target.rb +67 -8
  28. data/lib/adapi/config.rb +1 -0
  29. data/lib/adapi/keyword.rb +78 -11
  30. data/lib/adapi/version.rb +9 -1
  31. data/lib/httpi_request_monkeypatch.rb +1 -0
  32. data/test/factories/ad_group_factory.rb +4 -13
  33. data/test/factories/ad_text_factory.rb +1 -0
  34. data/test/test_helper.rb +3 -0
  35. data/test/unit/ad/ad_text_test.rb +2 -0
  36. data/test/unit/ad_group_test.rb +11 -2
  37. data/test/unit/ad_test.rb +2 -0
  38. data/test/unit/campaign_target_test.rb +15 -0
  39. metadata +67 -32
  40. data/README.rdoc +0 -162
  41. data/lib/collection.rb +0 -429
@@ -1,3 +1,4 @@
1
+ # encoding: utf-8
1
2
 
2
3
  require 'adapi'
3
4
 
@@ -1,3 +1,4 @@
1
+ # encoding: utf-8
1
2
 
2
3
  require 'adapi'
3
4
 
@@ -1,7 +1,7 @@
1
+ # encoding: utf-8
1
2
 
2
3
  require 'rubygems'
3
4
  require 'adwords_api'
4
- require 'collection'
5
5
  require 'yaml'
6
6
  require 'pp'
7
7
 
@@ -27,10 +27,13 @@ HTTPI.adapter = :curb
27
27
  # supress HTTPI output
28
28
  # HTTPI.log = false
29
29
 
30
- # load factories for development environment
31
- # require 'factory_girl'
32
- # Dir[ File.join(File.dirname(__FILE__), '../test/factories/*.rb') ].each { |f| require f }
33
-
34
30
  module Adapi
35
31
  API_VERSION = :v201101
36
32
  end
33
+
34
+ # check ruby version, should be 1.9
35
+ # FIXME there's gotta be more elegant way to do this
36
+ `ruby -v`.to_s =~ /^ruby (\d\.\d)\./
37
+ if $1.to_f < 1.9
38
+ puts "WARNING: please use ruby 1.9, adapi gem won't work properly in 1.8 and earlier versions"
39
+ end
@@ -1,3 +1,5 @@
1
+ # encoding: utf-8
2
+
1
3
  module Adapi
2
4
 
3
5
  # Ad == AdGroupAd
@@ -1,3 +1,5 @@
1
+ # encoding: utf-8
2
+
1
3
  module Adapi
2
4
  # Ad::TextAd == AdGroupAd::TextAd
3
5
  #
@@ -122,5 +124,18 @@ module Adapi
122
124
  first_only ? response.first : response
123
125
  end
124
126
 
127
+ # Converts text ad data to hash - of the same structure which is used when
128
+ # creating a complete campaign.
129
+ #
130
+ def to_hash
131
+ {
132
+ :headline => self[:headline],
133
+ :description1 => self[:description1],
134
+ :description2 => self[:description2],
135
+ :url => self[:url],
136
+ :display_url => self[:display_url]
137
+ }
138
+ end
139
+
125
140
  end
126
141
  end
@@ -1,3 +1,5 @@
1
+ # encoding: utf-8
2
+
1
3
  module Adapi
2
4
  class AdGroup < Api
3
5
 
@@ -19,6 +21,25 @@ module Adapi
19
21
  self.send "#{param_name}=", params[param_name.to_sym]
20
22
  end
21
23
 
24
+ # convert bids to GoogleApi format
25
+ #
26
+ # can be either string (just xsi_type) or hash (xsi_type with params)
27
+ # althogh I'm not sure if just string makes sense in this case
28
+ #
29
+ if @bids
30
+ unless @bids.is_a?(Hash)
31
+ @bids = { :xsi_type => @bids }
32
+ end
33
+
34
+ if @bids[:keyword_max_cpc] and not @bids[:keyword_max_cpc].is_a?(Hash)
35
+ @bids[:keyword_max_cpc] = {
36
+ :amount => {
37
+ :micro_amount => Api.to_micro_units(@bids[:keyword_max_cpc])
38
+ }
39
+ }
40
+ end
41
+ end
42
+
22
43
  @keywords ||= []
23
44
  @ads ||= []
24
45
 
@@ -71,7 +92,7 @@ module Adapi
71
92
  params.symbolize_keys!
72
93
  first_only = (amount.to_sym == :first)
73
94
 
74
- raise "No Campaign ID is required" unless params[:campaign_id]
95
+ raise "Campaign ID is required" unless params[:campaign_id]
75
96
 
76
97
  predicates = [ :campaign_id, :id ].map do |param_name|
77
98
  if params[param_name]
@@ -87,22 +108,40 @@ module Adapi
87
108
 
88
109
  response = AdGroup.new.service.get(selector)
89
110
 
90
- response = (response and response[:entries]) ? response[:entries] : []
111
+ ad_groups = (response and response[:entries]) ? response[:entries] : []
91
112
 
92
- #response.map! do |data|
93
- # TextAd.new(data[:ad].merge(:ad_group_id => data[:ad_group_id], :status => data[:status]))
94
- #end
113
+ ad_groups = ad_groups.slice(0,1) if first_only
95
114
 
96
- first_only ? response.first : response
115
+ # find keywords and ads
116
+ ad_groups.map! do |ad_group|
117
+ ad_group.merge(
118
+ :keywords => Keyword.shortened(Keyword.find(:all, :ad_group_id => ad_group[:id]).keywords),
119
+ :ads => Ad::TextAd.find(:all, :ad_group_id => ad_group[:id]).map(&:to_hash)
120
+ )
121
+ end
122
+
123
+ first_only ? ad_groups.first : ad_groups
97
124
  end
98
125
 
99
126
  def find_keywords(first_only = false)
100
127
  Keyword.find( (first_only ? :first : :all), :ad_group_id => self.id )
101
128
  end
102
129
 
130
+ # TODO find all types of ads
103
131
  def find_ads(first_only = false)
104
132
  Ad::TextAd.find( (first_only ? :first : :all), :ad_group_id => self.id )
105
133
  end
106
134
 
135
+ # Converts ad group data to hash - of the same structure which is used when
136
+ # creating an ad group.
137
+ #
138
+ def to_hash
139
+ ad_group_hash = {
140
+ :id => self[:id],
141
+ :name => self[:name],
142
+ :status => self[:status]
143
+ }
144
+ end
145
+
107
146
  end
108
147
  end
@@ -1,3 +1,5 @@
1
+ # encoding: utf-8
2
+
1
3
  module Adapi
2
4
 
3
5
  # http://code.google.com/apis/adwords/docs/reference/latest/AdGroupCriterionService.html
@@ -22,5 +24,17 @@ module Adapi
22
24
  super(params)
23
25
  end
24
26
 
27
+ def delete(criterion_id)
28
+ response = self.mutate(
29
+ :operator => 'REMOVE',
30
+ :operand => {
31
+ :ad_group_id => self.ad_group_id,
32
+ :criterion => { :id => criterion_id.to_i }
33
+ }
34
+ )
35
+
36
+ (response and response[:value])
37
+ end
38
+
25
39
  end
26
40
  end
@@ -1,3 +1,5 @@
1
+ # encoding: utf-8
2
+
1
3
  module Adapi
2
4
  class Api
3
5
  extend ActiveModel::Naming
@@ -28,6 +30,10 @@ module Adapi
28
30
  @params = params
29
31
  end
30
32
 
33
+ def to_param
34
+ self[:id]
35
+ end
36
+
31
37
  def persisted?
32
38
  false
33
39
  end
@@ -1,3 +1,5 @@
1
+ # encoding: utf-8
2
+
1
3
  module Adapi
2
4
  class Campaign < Api
3
5
 
@@ -34,7 +36,7 @@ module Adapi
34
36
  @bidding_strategy = { :xsi_type => @bidding_strategy }
35
37
  end
36
38
 
37
- if @bidding_strategy[:bid_ceiling]
39
+ if @bidding_strategy[:bid_ceiling] and not @bidding_strategy[:bid_ceiling].is_a?(Hash)
38
40
  @bidding_strategy[:bid_ceiling] = {
39
41
  :micro_amount => Api.to_micro_units(@bidding_strategy[:bid_ceiling])
40
42
  }
@@ -46,7 +48,9 @@ module Adapi
46
48
  # budget can be integer (amount) or hash
47
49
  @budget = { :amount => @budget } unless @budget.is_a?(Hash)
48
50
  @budget[:period] ||= 'DAILY'
49
- @budget[:amount] = { :micro_amount => Api.to_micro_units(@budget[:amount]) }
51
+ if @budget[:amount] and not @budget[:amount].is_a?(Hash)
52
+ @budget[:amount] = { :micro_amount => Api.to_micro_units(@budget[:amount]) }
53
+ end
50
54
  # PS: not sure if this should be a default. maybe we don't even need it
51
55
  @budget[:delivery_method] ||= 'STANDARD'
52
56
 
@@ -150,12 +154,20 @@ module Adapi
150
154
  Campaign.find(:first, :id => @id)
151
155
  end
152
156
 
157
+
158
+ # if nothing else than single number or string at the input, assume it's an
159
+ # id and we want to find campaign by id
160
+ #
153
161
  def self.find(amount = :all, params = {})
162
+ # find campaign by id - related syntactic sugar
163
+ if params.empty? and not amount.is_a?(Symbol)
164
+ params[:id] = amount.to_i
165
+ amount = :first
166
+ end
167
+
154
168
  params.symbolize_keys!
155
169
  first_only = (amount.to_sym == :first)
156
170
 
157
- raise "Campaign ID (:id param) is required" unless params[:id]
158
-
159
171
  predicates = [ :id ].map do |param_name|
160
172
  if params[param_name]
161
173
  {:field => param_name.to_s.camelcase, :operator => 'EQUALS', :values => params[param_name] }
@@ -165,7 +177,7 @@ module Adapi
165
177
  # TODO display the rest of the data
166
178
  # TODO get NetworkSetting - setting as in fields doesn't work
167
179
  selector = {
168
- :fields => ['Id', 'Name', 'Status', 'BiddingStrategy' ],
180
+ :fields => ['Id', 'Name', 'Status', 'BiddingStrategy'],
169
181
  :ordering => [{:field => 'Name', :sort_order => 'ASCENDING'}],
170
182
  :predicates => predicates
171
183
  }
@@ -188,5 +200,36 @@ module Adapi
188
200
  AdGroup.find( (first_only ? :first : :all), :campaign_id => self.id )
189
201
  end
190
202
 
203
+ # Returns complete campaign data: targets, ad groups, keywords and ads.
204
+ # Basically everything what you can set when creating a campaign.
205
+ #
206
+ def self.find_complete(campaign_id)
207
+ campaign = self.find(campaign_id)
208
+
209
+ campaign[:targets] = CampaignTarget.find(:campaign_id => campaign.to_param)
210
+
211
+ campaign[:ad_groups] = AdGroup.find(:all, :campaign_id => campaign.to_param)
212
+
213
+ campaign
214
+ end
215
+
216
+ # Converts campaign data to hash - of the same structure which is used when
217
+ # creating a campaign.
218
+ #
219
+ # PS: could be implemented more succintly, but let's leave it like this for
220
+ # now, code can change and this is more readable
221
+ #
222
+ def to_hash
223
+ {
224
+ :id => self[:id],
225
+ :name => self[:name],
226
+ :status => self[:status],
227
+ :budget => self[:budget],
228
+ :bidding_strategy => self[:bidding_strategy],
229
+ :targets => self[:targets],
230
+ :ad_groups => self[:ad_groups]
231
+ }
232
+ end
233
+
191
234
  end
192
235
  end
@@ -1,3 +1,5 @@
1
+ # encoding: utf-8
2
+
1
3
  module Adapi
2
4
 
3
5
  # http://code.google.com/apis/adwords/docs/reference/latest/CampaignTargetService.html
@@ -48,12 +50,15 @@ module Adapi
48
50
 
49
51
  alias :create :set
50
52
 
51
- # FIXME doesn't display everything, check the issues in google-adwords-api
52
- #
53
- def self.find(amount = :all, params = {})
53
+ def self.find(params = {})
54
54
  params.symbolize_keys!
55
- params = params[:conditions] if params[:conditions]
56
- first_only = (amount.to_sym == :first)
55
+
56
+ # by default, return skip target types that have no target data
57
+ params[:skip_empty_target_types] ||= true
58
+
59
+ if params[:conditions]
60
+ params[:campaign_id] = params[:campaign_id] || params[:conditions][:campaign_id]
61
+ end
57
62
 
58
63
  raise ArgumentError, "Campaing ID is required" unless params[:campaign_id]
59
64
 
@@ -63,15 +68,59 @@ module Adapi
63
68
 
64
69
  response = (response and response[:entries]) ? response[:entries] : []
65
70
 
66
- first_only ? response.first : response
71
+ # return everything or only
72
+ if params[:skip_empty_target_types]
73
+ response.select! { |target_type| target_type.has_key?(:targets) }
74
+ end
75
+
76
+ # TODO optionally return just certain target type(s)
77
+ # easy, just add condition (single type or array), filter and set
78
+ # :skip_empty_target_types option to false
79
+
80
+ # optionally convert to original input shortcuts specified by Adapi DSL
81
+ # TODO on second thought, no need to do that now. it's simpler to make
82
+ # CampaignTarget.new accept original AdWords data
83
+ # if params[:format] == :adapi_dsl
84
+ # response = Hash[ response.map { |t| CampaignTarget.parse_dsl(t) } ]
85
+ # end
86
+
87
+ response
67
88
  end
68
89
 
90
+ =begin TODO
91
+ def self.parse_dsl(target)
92
+ case target[:target_list_type]
93
+ when "LanguageTargetList"
94
+ [ :language, target[:targets].map { |t| t[:language_code] } ]
95
+ when "GeoTargetList"
96
+ targets = Hash[ target[:targets].map do |t|
97
+ target_type = t[:target_type].gsub(/Target$/, '')
98
+
99
+ case target_type
100
+ when :proximity
101
+ [ target_type, { :geo_point => '', :radius => '' } ]
102
+ when :city
103
+ else
104
+ [ target_type, t ]
105
+ end
106
+ end ]
107
+
108
+ [ :geo, targets ]
109
+ else
110
+ warn "Unsupported target type! %s" % target.inspect
111
+ [ target[:target_list_type], target[:targets] ]
112
+ end
113
+ end
114
+ =end
115
+
69
116
  # transform our own high-level target parameters to google low-level
117
+ #
118
+ # TODO allow to enter AdWords parameters in original format
70
119
  #
71
120
  def self.create_targets(target_type, target_data)
72
121
  case target_type
73
122
  when :language
74
- target_data.map { |language| { :language_code => language } }
123
+ target_data.map { |language| { :language_code => language.to_s.downcase } }
75
124
  # example: ['cz','sk'] => [{:language_code => 'cz'}, {:language_code => 'sk'}]
76
125
  when :geo
77
126
  target_data.map do |geo_type, geo_values|
@@ -101,7 +150,7 @@ module Adapi
101
150
  {
102
151
  :xsi_type => "#{geo_type.to_s.capitalize}Target",
103
152
  :excluded => false,
104
- "#{geo_type}_code".to_sym => geo_values
153
+ "#{geo_type}_code".to_sym => to_uppercase(geo_values)
105
154
  }
106
155
  end
107
156
  end
@@ -134,5 +183,15 @@ module Adapi
134
183
  Api.to_micro_units(x)
135
184
  end
136
185
 
186
+ # convert either single value or array of value to uppercase
187
+ #
188
+ def self.to_uppercase(values)
189
+ if values.is_a?(Array)
190
+ values.map { |value| value.to_s.upcase }
191
+ else
192
+ values.to_s.upcase
193
+ end
194
+ end
195
+
137
196
  end
138
197
  end
@@ -1,3 +1,4 @@
1
+ # encoding: utf-8
1
2
 
2
3
  # PS: what about this config setting?
3
4
  # Campaign.create(:data => campaign_data, :account => :my_account_alias)
@@ -1,3 +1,20 @@
1
+ # encoding: utf-8
2
+
3
+ # TODO user should be able to delete keywords
4
+ # TODO user should not see deleted keywords by default
5
+ #
6
+ # TODO program should be able to detect keywords in shortened or Google form
7
+ # automatically on input (outsource into separate method?)
8
+ #
9
+ # TODO user should be able to enter keywords in both shortened, parameterized and Google form
10
+ #
11
+ # FIXME broken Keyword.negative param
12
+
13
+ # Currently the Keyword DSL is a mess. There are basically three forms:
14
+ # * ultra short form on input: keyword example
15
+ # * shortened form: {:text=>"keyword example", :match_type=>"BROAD", :negative=>false}
16
+ # * google form
17
+
1
18
  module Adapi
2
19
  class Keyword < AdGroupCriterion
3
20
 
@@ -22,22 +39,22 @@ module Adapi
22
39
  super(params)
23
40
  end
24
41
 
25
- # TODO include formatting in create method
42
+ # Converts keyword specification from shortened form to Google format
26
43
  #
27
44
  def self.keyword_attributes(keyword)
28
45
  # detect match type
29
46
  match_type = case keyword[0]
30
- when '"'
31
- keyword = keyword.slice(1, (keyword.size - 2))
32
- 'PHRASE'
33
- when '['
34
- keyword = keyword.slice(1, (keyword.size - 2))
35
- 'EXACT'
36
- else
37
- 'BROAD'
47
+ when '"'
48
+ keyword = keyword.slice(1, (keyword.size - 2))
49
+ 'PHRASE'
50
+ when '['
51
+ keyword = keyword.slice(1, (keyword.size - 2))
52
+ 'EXACT'
53
+ else
54
+ 'BROAD'
38
55
  end
39
56
 
40
- # detect if keyword is negative
57
+ # sets whether keyword is negative or not
41
58
  negative = if (keyword =~ /^\-/)
42
59
  keyword.slice!(0, 1)
43
60
  true
@@ -74,6 +91,8 @@ module Adapi
74
91
  end
75
92
 
76
93
  def self.find(amount = :all, params = {})
94
+ params[:format] ||= :google # default, don't do anything with the data from google
95
+
77
96
  params.symbolize_keys!
78
97
  # this has no effect, it's here just to have the same interface everywhere
79
98
  first_only = (amount.to_sym == :first)
@@ -99,11 +118,59 @@ module Adapi
99
118
 
100
119
  response = (response and response[:entries]) ? response[:entries] : []
101
120
 
121
+ # for now, always return keywords in :google format
122
+ =begin
123
+ response = case params[:format].to_sym
124
+ when :short
125
+ Keyword.shortened(response)
126
+ when :params
127
+ Keyword.parameterized(response)
128
+ else
129
+ response
130
+ end
131
+ =end
132
+
102
133
  Keyword.new(
103
134
  :ad_group_id => params[:ad_group_id],
104
- :keywords => response.map { |keyword| keyword[:criterion] }
135
+ :keywords => response
105
136
  )
106
137
  end
107
138
 
139
+ # PS: create a better UI for this?
140
+ # Keyword.convert(:to => :params, :source => $google_keywords)
141
+ # and Keyword.parametrized($google_keywords) just calling that?
142
+
143
+ # Converts list of keywords from Google format to short format
144
+ #
145
+ def self.shortened(google_keywords = [])
146
+ self.parameterized(google_keywords).map do |keyword|
147
+ keyword[:text] = "-%s" % keyword[:text] if keyword[:negative]
148
+
149
+ case keyword[:match_type]
150
+ when 'PHRASE'
151
+ "\"%s\"" % keyword[:text]
152
+ when 'EXACT'
153
+ "[%s]" % keyword[:text]
154
+ else # 'BROAD'
155
+ keyword[:text]
156
+ end
157
+ end
158
+ end
159
+
160
+ # Converts list of keywords from Google format to params format
161
+ # (the way it can be entered into Keywords model)
162
+ #
163
+ def self.parameterized(google_keywords = [])
164
+ google_keywords.map do |keyword|
165
+ kw = keyword[:text][:criterion]
166
+
167
+ {
168
+ :text => kw[:text],
169
+ :match_type => kw[:match_type],
170
+ :negative => (keyword[:text][:xsi_type] == "NegativeAdGroupCriterion")
171
+ }
172
+ end
173
+ end
174
+
108
175
  end
109
176
  end