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 --> 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 += '>'
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()
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
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.