├── keywords.csv ├── report.csv ├── hourly_transactions.csv ├── .gitignore ├── requirements.txt ├── automater.apple_script ├── googleads.yaml ├── run.py ├── .env.sample ├── .editorconfig ├── get_account_labels.py ├── process.py ├── db_connect.py ├── create_bids.py ├── generate_refresh_token.py ├── README.md └── adwords_api.py /keywords.csv: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /report.csv: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /hourly_transactions.csv: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .env 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | googleads 2 | psycopg2 3 | -------------------------------------------------------------------------------- /automater.apple_script: -------------------------------------------------------------------------------- 1 | tell application "Terminal" activate set currentTab to do script ("cd PATH_TO_REPO") do script ("python run.py") in currentTab end tell -------------------------------------------------------------------------------- /googleads.yaml: -------------------------------------------------------------------------------- 1 | adwords: 2 | developer_token: DEV_TOKEN 3 | user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/37.0.2062.124 Safari/537.36" 4 | client_id: ID 5 | client_secret: SECRET 6 | client_customer_id: ACCOUNT_NUMBER 7 | refresh_token: REFRESH_TOKEN 8 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | import adwords_api 2 | import process 3 | 4 | from googleads import adwords 5 | 6 | adwords_client = adwords.AdWordsClient.LoadFromStorage() 7 | adwords_api.get_campaigns(adwords_client) 8 | for campaign_id, campaign_name in zip(adwords_api.campaign_ids, adwords_api.campaign_names): 9 | process.run_job(campaign_id, campaign_name) 10 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | export GOOGLE_CLIENT_ID=GOOGLE_CLIENT_ID 2 | export GOOGLE_CLIENT_SECRET=GOOGLE_CLIENT_SECRET 3 | export GOOGLE_CLIENT_CUSTOMER_ID=ACCOUNT_NUMBER 4 | export GOOGLE_REFRESH_TOKEN=REFRESH_TOKEN 5 | export DATABASE_DATABASE=YOUR_DATABASE_NAME 6 | export DATABASE_USER=USERNAME 7 | export DATABASE_PASSWORD=PASSWORD 8 | export DATABASE_HOST=HOSTNAME 9 | export DATABASE_PORT=1234 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # .editorconfig 2 | # http://editorconfig.org/ 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | indent_size = 2 9 | indent_style = space 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.bat] 14 | end_of_line = crlf 15 | 16 | [*.go] 17 | indent_size = 4 18 | indent_style = tab 19 | 20 | [*.html] 21 | indent_size = 4 22 | 23 | [*Makefile] 24 | indent_size = 4 25 | indent_style = tab 26 | 27 | [*.php] 28 | indent_size = 4 29 | 30 | [*.py] 31 | indent_size = 4 32 | 33 | [*.xml] 34 | indent_size = 4 35 | -------------------------------------------------------------------------------- /get_account_labels.py: -------------------------------------------------------------------------------- 1 | from googleads import adwords 2 | 3 | campaign_ids = [] 4 | campaign_names = [] 5 | campaign_labels = [] 6 | 7 | 8 | def get_campaigns(client): 9 | campaign_service = client.GetService('CampaignService', version='v201409') 10 | selector = { 11 | 'fields': ['Id', 'Name', 'Status', 'Labels'], 12 | 'predicates': [ 13 | { 14 | 'field': 'CampaignStatus', 15 | 'operator': 'EQUALS', 16 | 'values': 'ENABLED' 17 | } 18 | ] 19 | } 20 | page = campaign_service.get(selector) 21 | print page 22 | 23 | if __name__ == '__main__': 24 | # Initialize client object. 25 | adwords_client = adwords.AdWordsClient.LoadFromStorage() 26 | get_campaigns(adwords_client) 27 | -------------------------------------------------------------------------------- /process.py: -------------------------------------------------------------------------------- 1 | import adwords_api 2 | import create_bids 3 | import db_connect 4 | 5 | from googleads import adwords 6 | 7 | 8 | def run_job(campaign_id, campaign_name): 9 | adwords_client = adwords.AdWordsClient.LoadFromStorage() 10 | new_bids = [] 11 | db_connect.get_transactions(campaign_id) 12 | print "Retrived Transactions for Campaign " + campaign_name 13 | adwords_api.get_report(adwords_client, campaign_id) 14 | print "Retrived Adwords Spend for Campaign " + campaign_name 15 | adwords_api.get_keywords(adwords_client, campaign_id) 16 | print "Retrived Keywords for Campaign " + campaign_name 17 | create_bids.multipliers() 18 | print "Created Bid Multipliers for Campaign " + campaign_name 19 | create_bids.batch_parameters() 20 | for cpc in create_bids.keyword_cpc: 21 | l = round((int(cpc)*create_bids.multiple[0]), -4) 22 | new_bids.append(int(l)) 23 | create_bids.multiple = [] 24 | print "Created New Bids for Campaign " + campaign_name 25 | length = len(new_bids) 26 | print "Sending New Bids to Adwords for Campaign " + campaign_name 27 | adwords_api.change_bids(adwords_client, create_bids.ad_group_id, create_bids.keyword_id, new_bids, length) 28 | print "All Done on Campaign " + campaign_name 29 | -------------------------------------------------------------------------------- /db_connect.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import os 3 | import psycopg2 4 | import psycopg2.extras 5 | 6 | 7 | def get_transactions(campaign_id): 8 | query = """SELECT 9 | --Our DB is in UTC but Adwords will give you reports in your account's timezone. Conversions may be necessary 10 | extract(hour from convert_timezone('UTC', 'EST', created_at)) as "hour", 11 | CASE WHEN sum(margin) IS NULL THEN '1' ELSE sum(margin) END as margin 12 | FROM transactions 13 | WHERE created_at BETWEEN current_date - interval '14 day' AND current_date 14 | AND campaign_id = """+campaign_id+"""GROUP BY "hour"ORDER BY "hour" ASC;""" 15 | 16 | params = { 17 | 'database': os.getenv('DATABASE_DATABASE'), 18 | 'user': os.getenv('DATABASE_USER'), 19 | 'password': os.getenv('DATABASE_PASSWORD'), 20 | 'host': os.getenv('DATABASE_HOST'), 21 | 'port': os.getenv('DATABASE_PORT'), 22 | } 23 | 24 | try: 25 | conn = psycopg2.connect(**params) 26 | except: 27 | print "I am unable to connect to the database" 28 | 29 | cur = conn.cursor(cursor_factory=psycopg2.extras.DictCursor) 30 | cur.execute(query) 31 | spent_rows = cur.fetchall() 32 | 33 | with open('hourly_transactions.csv', 'wb') as csvfile: 34 | transaction_writer = csv.writer(csvfile, delimiter=' ', quotechar='|', quoting=csv.QUOTE_MINIMAL) 35 | transaction_writer.writerows(spent_rows) 36 | -------------------------------------------------------------------------------- /create_bids.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import datetime 3 | 4 | parameters = [] 5 | ad_group_id = [] 6 | keyword_id = [] 7 | keyword_cpc = [] 8 | hour = str(datetime.datetime.now().time().hour) 9 | multiple = [] 10 | 11 | 12 | def multipliers(): 13 | roi_goal = 2 14 | # Comment out line above and uncomment line before if you have a CPA goal 15 | # cpa_goal = 20 16 | spend_rows = [] 17 | trans_rows = [] 18 | 19 | with open('report.csv', 'rb') as spendcsvfile: 20 | reportreader = csv.reader(spendcsvfile) 21 | for row in reportreader: 22 | if row[0] == hour: 23 | spend_rows.append(float(row[1])/1000000) 24 | 25 | with open('hourly_transactions.csv', 'rb') as transcsvfile: 26 | transreportreader = csv.reader(transcsvfile, delimiter=' ') 27 | for row in transreportreader: 28 | if row[0] == hour: 29 | trans_rows.append(float(row[1])) 30 | 31 | # Create the bid multiplier 32 | z = trans_rows[0]/(roi_goal*spend_rows[0]) 33 | # Comment out line above and uncomment line below if you have a CPA goal 34 | # z = (cpa_goal*trans_rows[0])/spend_rows[0] 35 | print "Bid multiplier ="+str(z) 36 | multiple.append(z) 37 | return multiple 38 | 39 | 40 | # Parsing the keywords report into ad_group_ids, keyword_ids, and keyword_cpcs for later use in the changebids() function 41 | def batch_parameters(): 42 | with open('keywords.csv', 'rb') as keywordscsv: 43 | keywordsreader = csv.reader(keywordscsv) 44 | parameters = list(keywordsreader) 45 | for i in xrange(len(parameters)): 46 | if parameters[i][0] != 'Ad group ID': 47 | ad_group_id.append(parameters[i][0]) 48 | keyword_id.append(parameters[i][1]) 49 | keyword_cpc.append(parameters[i][2]) 50 | return ad_group_id, keyword_id, keyword_cpc 51 | -------------------------------------------------------------------------------- /generate_refresh_token.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # 3 | # Copyright 2014 Google Inc. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | """Generates refresh token for AdWords using the Installed Application flow.""" 18 | 19 | __author__ = 'Mark Saniscalchi' 20 | 21 | import os 22 | import sys 23 | 24 | from oauth2client import client 25 | 26 | # Your OAuth 2.0 Client ID and Secret. If you do not have an ID and Secret yet, 27 | # please go to https://console.developers.google.com and create a set. 28 | CLIENT_ID = os.getenv('GOOGLE_CLIENT_ID', None) 29 | CLIENT_SECRET = os.getenv('GOOGLE_CLIENT_SECRET', None) 30 | 31 | # The AdWords API OAuth 2.0 scope. 32 | SCOPE = u'https://www.googleapis.com/auth/adwords' 33 | 34 | 35 | def main(): 36 | """Retrieve and display the access and refresh token.""" 37 | flow = client.OAuth2WebServerFlow( 38 | client_id=CLIENT_ID, 39 | client_secret=CLIENT_SECRET, 40 | scope=[SCOPE], 41 | user_agent='Ads Python Client Library', 42 | redirect_uri='urn:ietf:wg:oauth:2.0:oob') 43 | 44 | authorize_url = flow.step1_get_authorize_url() 45 | 46 | print ('Log into the Google Account you use to access your AdWords account' 47 | 'and go to the following URL: \n%s\n' % (authorize_url)) 48 | print 'After approving the token enter the verification code (if specified).' 49 | code = raw_input('Code: ').strip() 50 | 51 | try: 52 | credential = flow.step2_exchange(code) 53 | except client.FlowExchangeError, e: 54 | print 'Authentication has failed: %s' % e 55 | sys.exit(1) 56 | else: 57 | print ('OAuth 2.0 authorization successful!\n\n' 58 | 'Your access token is:\n %s\n\nYour refresh token is:\n %s' 59 | % (credential.access_token, credential.refresh_token)) 60 | 61 | 62 | if __name__ == '__main__': 63 | main() 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Conductor 2 | 3 | Conductor is a set of scripts that use data from your internal database in order to build out hourly bid modifiers for your Adwords campaigns. 4 | 5 | ### What does conductor need? 6 | 7 | - Access to python (we use 2.7) and pip (to install requirements) 8 | - Access to a postgres database (we use redshift) 9 | 10 | ### What can Conductor do? 11 | 12 | * Modify bids at the keyword level for all tagged campaigns based on time of day performance 13 | * Pull information from your database in order to calculate bid modifiers 14 | * Work off a CPA or ROI target 15 | 16 | ### What can't Conductor do? 17 | 18 | * Modifiy bids based on individual adgroup or keyword level performance 19 | * Modify bids based on time of day _and_ day of week 20 | 21 | ### How do I set Conductor up? 22 | 23 | 1. Get adwords API access [here](https://developers.google.com/adwords/api/docs/signingup) 24 | 2. Set up [labels](https://support.google.com/adwords/answer/2475865?hl=en) for campaigns you'd like this script to operate on. 25 | 3. Clone this repo down to your computer. If you've never done that before, follow the instructions [here](https://help.github.com/articles/fetching-a-remote/#clone) 26 | 4. Follow the instructions [here](https://developers.google.com/adwords/api/docs/first-request) for finding all necessary credentials. 27 | 6. Set up database credentials [here](https://github.com/seatgeek/adwords-hourly-bid-updater/blob/master/db_connect.py) and the query that either gets you revenue for an ROI calculation, or a count of KPIs for a CPA calculation. 28 | * This file is set up for a Postgres based DB. If you work off of a MySQL DB then you'll need to follow the instructions found [here](http://dev.mysql.com/doc/connector-python/en/connector-python-example-cursor-select.html) for accessing your DB. 29 | 5. Add your `CLIENT_ID` and `CLIENT_SECRET` to `generate_refresh_token.py` then run the command `python generate_refresh_token.py` in the command line. Follow the instructions in order to get your refresh token. 30 | 6. Add your `developer_token`, `client_id`, `client_secret`, `client_customer_id`, and `refresh_token` to the file `googleads.yaml`. Copy that file to your home directory. 31 | 7. If you are going to use a label to decide which campaigns to run this on, then you must run the following command `python get_account_labels.py`. You will see an output of all campaigns with their attached labels. Copy the `LabelId` for your desired label. 32 | * If you would instead prefer to run this script on every campaign in your account. You must remove lines 29 - 33 in the `adwords_api.py` file found [here](https://github.com/seatgeek/adwords-hourly-bid-updater/blob/master/adwords_api.py#LL29-33) 33 | 8. Paste the `LabelId` into [this](https://github.com/seatgeek/adwords-hourly-bid-updater/blob/master/adwords_api.py#LL32) line in the file `adwords_api.py` 34 | 9. Set up your ROI or CPA goal in `create_bids.py` found [here](https://github.com/seatgeek/adwords-hourly-bid-updater/blob/master/create_bids.py) 35 | 10. Install the python requirements using `pip install -r requirements.txt` from the base of this repository. 36 | 11. Run this command: `python run.py`. You should see the script operating on all desired campaigns! 37 | 38 | ### How do I automate this script to run every hour? 39 | 40 | You can always ask super nicely to get some help deploying the app to [the cloud](https://www.google.com/search?q=the+cloud&tbm=isch) from your trusty development team...or figure it out yourself! 41 | 42 | I haven't taken the time to do this yet, and plan to do more work on this in the future anyway. In the meantime I have it set up to run automatically every hour on my work comp. If your work comp doesn't live at work...this solution may be a problem. 43 | 44 | If you've never played around with Applescripts, they are a great way to automate running files on your OSX machine. [This](http://macosxautomation.com/applescript/firsttutorial/index.html) is a pretty good tutorial on Applescripts. 45 | 46 | Anyway, just open up Applescripts and paste the content of `automater.apple_script` found [here](https://github.com/seatgeek/adwords-hourly-bid-updater/blob/master/automater.apple_script) into the editor. Make sure you edit line 3 to include the path to the repo. 47 | 48 | Save the script as an application. 49 | 50 | You can use Apple Calendar's "Alert" [feature](https://discussions.apple.com/docs/DOC-4082) in order to open a file...which in this case will be the application you just created. Set the file to run every hour on the hour and you're good to go. 51 | 52 | 53 | #### Work at SeatGeek 54 | 55 | If this is how you like to solve problems you run into every day at work, then I think you'd get along great with our team. We're hiring for almost everything! Check out our [Jobs Page](https://seatgeek.com/jobs) 56 | -------------------------------------------------------------------------------- /adwords_api.py: -------------------------------------------------------------------------------- 1 | import os 2 | import datetime 3 | import time 4 | 5 | from googleads import errors 6 | 7 | 8 | hour = datetime.datetime.now().time().hour 9 | 10 | ad_group_id = [] 11 | keyword_id = [] 12 | keyword_bid = [] 13 | campaign_ids = [] 14 | campaign_names = [] 15 | 16 | 17 | def get_campaigns(client): 18 | campaign_service = client.GetService('CampaignService', version='v201409') 19 | selector = { 20 | 'fields': ['Id', 'Name', 'Status'], 21 | 'predicates': [ 22 | { 23 | 'field': 'Labels', 24 | 'operator': 'CONTAINS_ANY', 25 | 'values': '{LABELID}' 26 | }, 27 | { 28 | 'field': 'CampaignStatus', 29 | 'operator': 'EQUALS', 30 | 'values': 'ENABLED' 31 | } 32 | ] 33 | } 34 | page = campaign_service.get(selector) 35 | for campaign in page['entries']: 36 | campaign_ids.append(str(campaign['id'])) 37 | campaign_names.append(str(campaign['name'])) 38 | return campaign_ids 39 | return campaign_names 40 | 41 | 42 | # This function pulls a report of Campaign cost in the last 14 days by hour in order to compare to hourly revenue later. 43 | def get_report(client, campaign_id): 44 | report_downloader = client.GetReportDownloader(version='v201409') 45 | report_name = 'report' 46 | path = os.path.join(os.path.dirname(os.path.realpath(__file__)), report_name + ".csv") 47 | # Create report definition. 48 | report = { 49 | 'reportName': 'Hourly Report', 50 | 'dateRangeType': 'LAST_14_DAYS', 51 | 'reportType': 'CAMPAIGN_PERFORMANCE_REPORT', 52 | 'downloadFormat': 'CSV', 53 | 'selector': { 54 | 'fields': ['HourOfDay', 'Cost'], 55 | 'predicates': [ 56 | { 57 | 'field': 'CampaignId', 58 | 'operator': 'EQUALS', 59 | 'values': campaign_id 60 | } 61 | ] 62 | }, 63 | # Enable to get rows with zero impressions. 64 | 'includeZeroImpressions': 'false' 65 | } 66 | 67 | # You can provide a file object to write the output to. For this demonstration 68 | # we use sys.stdout to write the report to the screen. 69 | f = open(path, 'wb') 70 | f.write(report_downloader.DownloadReportAsString(report, skip_report_header=True, skip_report_summary=True)) 71 | f.close() 72 | 73 | 74 | # This function pulls a report on the Average CPC over the last 14 days. Unfortunately Adwords API doesn't let you pull 75 | # keyword reports by Hour of Day 76 | def get_keywords(client, campaign_id): 77 | report_downloader = client.GetReportDownloader(version='v201409') 78 | report_name = 'keywords' 79 | path = os.path.join(os.path.dirname(os.path.realpath(__file__)), report_name + ".csv") 80 | # Create report definition. 81 | report = { 82 | 'reportName': 'Hourly Report', 83 | 'dateRangeType': 'LAST_14_DAYS', 84 | 'reportType': 'KEYWORDS_PERFORMANCE_REPORT', 85 | 'downloadFormat': 'CSV', 86 | 'selector': { 87 | 'fields': ['AdGroupId', 'Id', 'AverageCpc'], 88 | 'predicates': [ 89 | { 90 | 'field': 'CampaignId', 91 | 'operator': 'EQUALS', 92 | 'values': campaign_id 93 | } 94 | ] 95 | }, 96 | # This script only operates on keywords that have seen impressions in the last 14 days 97 | 'includeZeroImpressions': 'false' 98 | } 99 | 100 | f = open(path, 'wb') 101 | f.write(report_downloader.DownloadReportAsString(report, skip_report_header=True, skip_report_summary=True)) 102 | f.close() 103 | 104 | 105 | # This function actually changes the bids on your keywords as the last step 106 | def change_bids(client, ad_group_id, criterion_id, new_bid, length): 107 | RETRY_INTERVAL = 10 108 | RETRIES_COUNT = 30 109 | # Initialize appropriate service. 110 | mutate_job_service = client.GetService('MutateJobService', version='v201409') 111 | 112 | # Create list of all operations for the job. 113 | operations = [] 114 | 115 | # Create AdGroupCriterionOperations to change keywords. 116 | for x in range(0, length): 117 | operations.append({ 118 | 'xsi_type': 'AdGroupCriterionOperation', 119 | 'operator': 'SET', 120 | 'operand': { 121 | 'xsi_type': 'BiddableAdGroupCriterion', 122 | 'adGroupId': ad_group_id[x], 123 | 'criterion': { 124 | 'id': criterion_id[x], 125 | }, 126 | 'biddingStrategyConfiguration': { 127 | 'bids': [ 128 | { 129 | 'xsi_type': 'CpcBid', 130 | 'bid': { 131 | 'microAmount': new_bid[x] 132 | } 133 | } 134 | ] 135 | } 136 | } 137 | }) 138 | 139 | # You can specify up to 3 job IDs that must successfully complete before 140 | # this job can be processed. 141 | policy = { 142 | 'prerequisiteJobIds': [] 143 | } 144 | # Call mutate to create a new job. 145 | 146 | response = mutate_job_service.mutate(operations, policy) 147 | 148 | if not response: 149 | raise errors.GoogleAdsError('Failed to submit a job; aborting.') 150 | job_id = response['id'] 151 | print 'Job with ID %s was successfully created.' % job_id 152 | 153 | # Create selector to retrieve job status and wait for it to complete. 154 | selector = { 155 | 'xsi_type': 'BulkMutateJobSelector', 156 | 'jobIds': [job_id] 157 | } 158 | 159 | time.sleep(RETRY_INTERVAL) 160 | # Poll for job status until it's finished. 161 | print 'Retrieving job status...' 162 | for i in range(RETRIES_COUNT): 163 | job_status_response = mutate_job_service.get(selector) 164 | status = job_status_response[0]['status'] 165 | if status in ('COMPLETED', 'FAILED'): 166 | break 167 | print ('[%d] Current status is \'%s\', waiting %d seconds to retry...' % 168 | (i, status, RETRY_INTERVAL)) 169 | time.sleep(RETRY_INTERVAL) 170 | 171 | if status == 'FAILED': 172 | raise errors.GoogleAdsError('Job failed with reason: \'%s\'' % 173 | job_status_response[0]['failure_reason']) 174 | if status in ('PROCESSING', 'PENDING'): 175 | raise errors.GoogleAdsError('Job did not complete within %d seconds' % 176 | (RETRY_INTERVAL * (RETRIES_COUNT - 1))) 177 | 178 | # Status must be COMPLETED. 179 | # Get the job result. Here we re-use the same selector. 180 | result_response = mutate_job_service.getResult(selector) 181 | 182 | # Output results. 183 | index = 0 184 | for result in result_response['SimpleMutateResult']['results']: 185 | if 'PlaceHolder' in result: 186 | print 'Operation [%d] - FAILED' % index 187 | else: 188 | print 'Operation [%d] - SUCCEEDED' % index 189 | index += 1 190 | 191 | 192 | # Uncomment these various lines in order to QA each step. 193 | # if __name__ == '__main__': 194 | # CAMPAIGN_ID = 'INSERT A CAMPAIGN ID' 195 | # adwords_client = adwords.AdWordsClient.LoadFromStorage() 196 | # get_keywords(adwords_client, CAMPAIGN_ID) 197 | # get_campaigns(adwords_client) 198 | # get_report(adwords_client, CAMPAIGN_ID) 199 | --------------------------------------------------------------------------------