Yahoo Search Marketing: Python SOAP binding

Recently, I’ve been trying to make Yahoo Search Marketing API work with Python. SOAPpy, you know, as it is used by Adwords API and thus seemed fine to me. But the very first API call using SOAPpy (getCampaignsByAccountID) failed with the message “Account ID specified in the header does not match the one specified in the parameter.” although they both were fine. Yahoo team refused to give any support on this. Long story short, I’ve found out that YWS API really does care about the parameter order.

So if you pass the parameters like this:

<ns1:accountID>1234567890</ns1:accountID>
<ns1:includeDeleted>false</ns1:includeDeleted>

it passes by, but if you pass them like:

<ns1:includeDeleted>false</ns1:includeDeleted>
<ns1:accountID>1234567890</ns1:accountID>

it doesn’t work. It doesn’t look reasonable (why do we name parameters in this case, for God’s sake!) but it makes using SOAPpy in its present form not possible, and here’s the reason. In SOAPpy, you pass parameters to function in natural Python way and they are processed using **kwargs and so on. But in this case you can’t be sure about the order they will go in final request. **kwargs is a dictionary. So, to be sure, you must pass the parameters as a tuple for example.

Finally, when I was at the end of realizing why this code doesn’t work, my testing code looked like something semi-complete so I’ve left it that way and now I’m using it in my project. It’s not perfect but I’m happy with it at the moment because it works and doesn’t require my attention. Here’s the code (including some snippets to parse XML SOAP response):

import re
from datetime import datetime
from urllib2 import urlopen, Request, HTTPError, URLError
import xml.sax.handler

"""
    Yahoo Enterprise Web Services implementation, V5

    No SOAP bindings, SOAPpy is old and poorly documented, other bindings
    don't work from scratch.

"""

def xml2obj(src):
    """
    A simple function to converts XML data into native Python object.
    """

    non_id_char = re.compile('[^_0-9a-zA-Z]')
    def _name_mangle(name):
        return non_id_char.sub('_', name)

    class DataNode(object):
        def __init__(self):
            self._attrs = {}    # XML attributes and child elements
            self.data = None    # child text data
        def __len__(self):
            # treat single element as a list of 1
            return 1
        def __getitem__(self, key):
            if isinstance(key, basestring):
                return self._attrs.get(key,None)
            else:
                return [self][key]
        def __contains__(self, name):
            return self._attrs.has_key(name)
        def __nonzero__(self):
            return bool(self._attrs or self.data)
        def __getattr__(self, name):
            if name.startswith('__'):
                # need to do this for Python special methods???
                raise AttributeError(name)
            return self._attrs.get(name,None)
        def _add_xml_attr(self, name, value):
            if name == 'xsi_nil' and value == 'true':
                self.data = None
                return
            if name in self._attrs:
                # multiple attribute of the same name are represented by a list
                children = self._attrs[name]
                if not isinstance(children, list):
                    children = [children]
                    self._attrs[name] = children
                children.append(value)
            else:
                self._attrs[name] = value
        def __str__(self):
            return self.data or ''
        def __repr__(self):
            items = sorted(self._attrs.items())
            if self.data:
                items.append(('data', self.data))
            return u'{%s}' % ', '.join([u'%s:%s' % (k,repr(v)) for k,v in items])

    class TreeBuilder(xml.sax.handler.ContentHandler):
        def __init__(self):
            self.stack = []
            self.root = DataNode()
            self.current = self.root
            self.text_parts = []
        def startElement(self, name, attrs):
            self.stack.append((self.current, self.text_parts))
            self.current = DataNode()
            self.text_parts = []
            # xml attributes --&gt; python attributes
            for k, v in attrs.items():
                self.current._add_xml_attr(_name_mangle(k), v)
        def endElement(self, name):
            text = ''.join(self.text_parts).strip()
            if text:
                self.current.data = text
            if self.current._attrs:
                obj = self.current
            else:
                # a text only node is simply represented by the string
                obj = text or ''
            self.current, self.text_parts = self.stack.pop()
            self.current._add_xml_attr(_name_mangle(name), obj)
        def characters(self, content):
            self.text_parts.append(content)

    builder = TreeBuilder()
    if isinstance(src,basestring):
        xml.sax.parseString(src, builder)
    else:
        xml.sax.parse(src, builder)
    return builder.root._attrs.values()[0]

class YahooEWS(object):

    VERSION = 'V5'
    LOCATION_ENDPOINT = 'https://marketing.ews.yahooapis.com/services'
    SERVICE_URL = None

    SERVICES = {
        'LocationService': ['getMasterAccountLocation'],
        'CampaignService': ['addCampaign', 'addCampaigns', 'deleteCampaign', 'deleteCampaigns', 'deleteGeographicLocationFromCampaign', 'getCampaign', 'getCampaignAdGroupCount', 'getCampaignKeywordCount', 'getCampaigns', 'getCampaignsByAccountID', 'getCampaignsByAccountIDByCampaignStatus', 'getGeographicLocationForCampaign', 'getMinBidForCampaignOptimizationGuidelines', 'getOptimizationGuidelinesForCampaign', 'getStatusForCampaign', 'getTargetingForCampaign', 'setCampaignOptimizationON', 'setGeographicLocationForCampaign', 'setOptimizationGuidelinesForCampaign', 'setTargetingForCampaign', 'updateCampaign', 'updateCampaigns', 'updateStatusForCampaign', 'updateStatusForCampaigns'],
        'AccountService': ['addAccount', 'addMoney', 'deleteBlockedDomainListForAccount', 'deleteContinentBlockListFromAccount', 'getAccount', 'getAccountBalance', 'getAccounts', 'getAccountStatus', 'getActiveCreditCard', 'getBlockedDomainListForAccount', 'getChargeAmount', 'getContinentBlockListForAccount', 'setActiveCreditCard', 'setBlockedDomainListForAccount', 'setChargeAmount', 'setContinentBlockListForAccount', 'updateAccount', 'updateStatusForAccount'],
        'AdGroupService': ['addAdGroup', 'addAdGroups', 'deleteAdGroup', 'deleteAdGroups', 'getAdGroup', 'getAdGroupAdCount', 'getAdGroupContentMatchMaxBid', 'getAdGroupExcludedWordsCount', 'getAdGroupKeywordCount', 'getAdGroups', 'getAdGroupsByCampaignID', 'getAdGroupsByCampaignIDByStatus', 'getAdGroupSponsoredSearchMaxBid', 'getContentMatchMinBidForAdGroupOptimizationGuidelines', 'getOptimizationGuidelinesForAdGroup', 'getSponsoredSearchMinBidForAdGroup', 'getSponsoredSearchMinBidForAdGroupOptimizationGuidelines', 'getSponsoredSearchMinBidForAdGroups', 'getStatusForAdGroup', 'moveAdGroup', 'setAdGroupContentMatchMaxBid', 'setAdGroupSponsoredSearchMaxBid', 'setOptimizationGuidelinesForAdGroup', 'updateAdGroup', 'updateAdGroups', 'updateStatusForAdGroup', 'updateStatusForAdGroups'],
        'AdService': ['addAd', 'addAds', 'deleteAd', 'deleteAds', 'getAd', 'getAds', 'getAdsByAdGroupByParticipatesInMarketplace', 'getAdsByAdGroupID', 'getAdsByAdGroupIDByEditorialStatus', 'getAdsByAdGroupIDByStatus', 'getEditorialReasonsForAd', 'getEditorialReasonText', 'getReasonsForAdNotParticipatingInMarketplace', 'getStatusForAd', 'getUpdateForAd', 'setAdUrl', 'updateAd', 'updateAds', 'updateStatusForAd', 'updateStatusForAds'],
        'KeywordService': ['addKeyword', 'addKeywords', 'copyKeyword', 'deleteKeyword', 'deleteKeywords', 'getEditorialReasonsForKeyword', 'getEditorialReasonText', 'getKeyword', 'getKeywords', 'getKeywordsByAccountID', 'getKeywordsByAdGroupByParticipatesInMarketplace', 'getKeywordsByAdGroupBySponsoredSearchBidStatus', 'getKeywordsByAdGroupID', 'getKeywordsByAdGroupIDByEditorialStatus', 'getKeywordsByAdGroupIDByStatus', 'getKeywordSponsoredSearchMaxBid', 'getOptimizationGuidelinesForKeyword', 'getReasonsForKeywordNotParticipatingInMarketplace', 'getSponsoredSearchMinBidForKeywordOptimizationGuidelines', 'getSponsoredSearchMinBidForKeywordString', 'getSponsoredSearchMinBidForKeywordStrings', 'getSponsoredSearchMinBidUpdatesByAdGroupId', 'getStatusForKeyword', 'getUpdateForKeyword', 'moveKeyword', 'setKeywordUrl', 'setOptimizationGuidelinesForKeyword', 'updateKeyword', 'updateKeywords', 'updateSponsoredSearchMaxBidForKeyword', 'updateSponsoredSearchMaxBidForKeywords', 'updateStatusForKeyword', 'updateStatusForKeywords'],
    }

    def __init__(self, options):
        self.options = options

    def _service_url(self):
        if not self.SERVICE_URL is None:
            return self.SERVICE_URL

        self.SERVICE_URL = self.call('getMasterAccountLocation')
        return self.SERVICE_URL

    def _soap_header(self):
        res = ''
        for k in self.options.keys():
            res += '%s' % (k, self.options[k], k)
        res += ''
        return res

    def call(self, function, args=[], service=None):
        if service is None:
            service = self._service_lookup(function)

        post_data = '\r\n\
' % self.VERSION
        post_data += self._soap_header()
        post_data += ''
        else:
            post_data += '&gt;'
            for arg in args:
                # False -> false
                value = arg[1]
                if value is False:
                    value = 'false'
                post_data += '%s' % (arg[0], value, arg[0])
            post_data += '' % function

        post_data += ''
        if service == 'LocationService':
            request_url = '%s/%s/LocationService' % (self.LOCATION_ENDPOINT, self.VERSION)
        else:
            request_url = '%s/%s/%s' % (self._service_url(), self.VERSION, service)

        req = Request(request_url, post_data)
        req.add_header('Content-type', 'text/xml; charset=utf-8')
        req.add_header('User-Agent', 'YWS SOAP')
        req.add_header('SOAPAction', '""')
        try:
            r = urlopen(req).read()
        except HTTPError, e:
            raise Exception, xml2obj(e.read())['soap_Body']['soap_Fault']['faultstring']
        except URLError, e:
            raise Exception, 'Error: ' % e.reason

        res = xml2obj(r)['soap_Body']['%sResponse' % function]['out']
        if not res.data is None:
            return res.data

        return res

    def _service_lookup(self, function):
        for service in self.SERVICES.keys():
            if function in self.SERVICES[service]:
                return service
        raise Exception, 'Unknown function, please use service argument to call()'

Usage example:

import sys
from misc.yahoo_ews import YahooEWS
options = {
    'username': 'user',
    'password': 'password',
    'masterAccountID': 'mymasteraccountid',
    'license': 'mylicensehere',
    'accountID': 'myaccountidhere'
}
yahoo = YahooEWS(options)

try:
    campaigns = yahoo.call(
        'getCampaignsByAccountID',
        [
         ('accountID', options['accountID']),
         ('includeDeleted', False)
        ])
    for c in campaigns.Campaign:
        print 'Campaign id: %s, name: %s' % (c.ID, c.name)
except:
    print sys.exc_info()

2 Comments

  1. David says:

    I tried the code by coping and pasting…but it did not work….to many errors e.g.
    135 post_data = ‘\r\n\
    136 .’ % self.VERSION

    It would be sooooo awesome if I could get this code to work…

    Thanks

  2. admin says:

    David, this code is a copypaste from my working code so it must be working. In the example above I guess it’s some kind of indentation problem, for example a space after the backslash or smth like that.

Leave a Reply