lstejskal/adapi

View on GitHub
lib/adapi/keyword.rb

Summary

Maintainability
A
3 hrs
Test Coverage
# encoding: utf-8

# TODO user should be able to delete keywords
# TODO user should not see deleted keywords by default
#
# TODO program should be able to detect keywords in shortened or Google form
# automatically on input (outsource into separate method?)
#
# TODO user should be able to enter keywords in both shortened, parameterized and Google form
# 
# FIXME broken Keyword.negative param 

# Currently the Keyword DSL is a mess. There are basically three forms:
# * ultra short form on input: keyword example
# * shortened form: {:text=>"keyword example", :match_type=>"BROAD", :negative=>false} 
# * google form

module Adapi
  class Keyword < AdGroupCriterion

    attr_accessor :keywords

    def attributes
      super.merge( keywords: keywords )
    end

    def initialize(params = {})
      params[:service_name] = :AdGroupCriterionService

      @xsi_type = 'AdGroupCriterion'

      %w{ keywords }.each do |param_name|
        self.send "#{param_name}=", params[param_name.to_sym]
      end

      self.keywords ||= []
      self.keywords.map! { |k| Keyword.keyword_attributes(k) }

      super(params)
    end

    # Converts keyword specification from shortened form to Google format
    #
    def self.keyword_attributes(keyword)
      # detect match type
      match_type = case keyword[0]
      when '"'
        keyword = keyword.slice(1, (keyword.size - 2))
        'PHRASE'
      when '['
        keyword = keyword.slice(1, (keyword.size - 2))
        'EXACT'
      else
        'BROAD'
      end

      # sets whether keyword is negative or not
      negative = if (keyword =~ /^\-/)
        keyword.slice!(0, 1)
        true
      else
        false
      end

      { :text => keyword, :match_type => match_type, :negative => negative }
    end


    def create
      operations = @keywords.map do |keyword|
        {
          :operator => 'ADD', 
          :operand => {
            :xsi_type => (keyword[:negative] ? 'NegativeAdGroupCriterion' : 'BiddableAdGroupCriterion'),
            :ad_group_id => @ad_group_id,
            :criterion => {
              :xsi_type => 'Keyword',
              :text => keyword[:text],
              :match_type => keyword[:match_type]
            }
          }
        }
      end

      response = self.mutate(operations)

      # check for PolicyViolationErrors, set exemptions and try again
      # do it only once. from my experience with AdWords API, multiple retries are bad practice
      if self.errors[:PolicyViolationError].any?

        self.errors[:PolicyViolationError].each do |e|
          i = e.delete(:operation_index) 
          operations[i][:exemption_requests] = [ { :key => e } ]
        end

        self.errors.clear

        response = self.mutate(operations)
      end

      return false if self.errors.any?
      
      self.keywords = response[:value].map { |keyword| keyword[:criterion] }

      true
    end

    def self.find(amount = :all, params = {})
      params[:format] ||= :google # default, don't do anything with the data from google
      
      params.symbolize_keys!
      # this has no effect, it's here just to have the same interface everywhere
      first_only = (amount.to_sym == :first)

      # we need ad_group_id
      raise ArgumentError, "AdGroup ID is required" unless params[:ad_group_id]
 
      # basic predicates for searching keywords - both are required
      predicates = [
        { :field => 'CriteriaType', :operator => 'EQUALS', :values => [ 'KEYWORD' ] },
        { :field => 'AdGroupId', :operator => 'EQUALS', :values => [ params[:ad_group_id] ] }
      ]
      # supported parameters: id
      predicates += [ :id ].map do |param_name|
        if params[param_name]
          {:field => param_name.to_s.camelcase, :operator => 'EQUALS', :values => params[param_name] }
        end
      end.compact

      # Get all the criteria for this ad group.
      selector = {
        :fields => ['Id', 'CriteriaType', 'KeywordText'],
        :ordering => [{ :field => 'AdGroupId', :sort_order => 'ASCENDING' }],
        :predicates => predicates
        # obsolete, not needed in newer versions of AdWords API 
        # :paging => { :start_index => 0, :number_results => 10 }
      }

      response = Keyword.new.service.get(selector)

      response = (response and response[:entries]) ? response[:entries] : []

# for now, always return keywords in :google format
=begin
      response = case params[:format].to_sym
      when :short
        Keyword.shortened(response)
      when :params
        Keyword.parameterized(response)
      else
        response
      end
=end

      Keyword.new(
        :ad_group_id => params[:ad_group_id],
        :keywords => response
      )
    end

    # PS: create a better UI for this?
    # Keyword.convert(:to => :params, :source => $google_keywords)
    # and Keyword.parametrized($google_keywords) just calling that?

    # Converts list of keywords from Google format to short format
    #
    def self.shortened(google_keywords = [])
      self.parameterized(google_keywords).map do |keyword|
        keyword[:text] = "-%s" % keyword[:text] if keyword[:negative]
        
        case keyword[:match_type]
        when 'PHRASE'
          "\"%s\"" % keyword[:text]
        when 'EXACT'
          "[%s]" % keyword[:text]
        else # 'BROAD'
          keyword[:text]
        end
      end
    end

    # Converts list of keywords from Google format to params format
    # (the way it can be entered into Keywords model)
    # 
    def self.parameterized(google_keywords = [])
      google_keywords.map do |keyword|        
        kw = keyword[:text][:criterion]
        
        {
          :text => kw[:text],
          :match_type => kw[:match_type],
          :negative => (keyword[:text][:xsi_type] == "NegativeAdGroupCriterion")
        }
      end
    end

  end
end