├── .gitignore ├── galytics3 ├── __init__.py ├── decorators.py └── galytics3.py ├── setup.py ├── tests.py └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | 3 | .pytest_cache 4 | .cache -------------------------------------------------------------------------------- /galytics3/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from galytics3.galytics3 import GoogleAnalytics 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import setuptools 3 | 4 | VERSION = '2019.4.9' 5 | 6 | setuptools.setup( 7 | name='galytics3', 8 | version=VERSION, 9 | description="Обертка над библиотекой google_api_python_client для работы с API Google Analytics v3", 10 | packages=setuptools.find_packages(), 11 | install_requires=['pandas', 'google_api_python_client', 'oauth2client'] 12 | ) 13 | -------------------------------------------------------------------------------- /galytics3/decorators.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import logging 3 | import time 4 | 5 | 6 | def retry_wrap(retries=3, sleep=0.1, exceptions=(Exception, ConnectionError)): 7 | def wrapper1(func): 8 | def wrapper2(*args, **kwargs): 9 | for i in range(retries): 10 | try: 11 | result = func(*args, **kwargs) 12 | except exceptions: 13 | if i == retries - 1: 14 | raise 15 | logging.exception("Function error {}".format(func.__name__)) 16 | logging.warning( 17 | "Retry functions {}, run number {}".format(func.__name__, i + 2) 18 | ) 19 | time.sleep(sleep) 20 | else: 21 | return result 22 | 23 | return wrapper2 24 | 25 | return wrapper1 26 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from datetime import datetime 3 | 4 | from pandas import set_option 5 | 6 | from galytics3 import GoogleAnalytics 7 | 8 | set_option('display.max_columns', 100) 9 | set_option('display.width', 1500) 10 | 11 | api = GoogleAnalytics(refresh_token='', 12 | client_id='', 13 | client_secret='') 14 | 15 | 16 | def test_get_accounts(): 17 | r = api.get_accounts() 18 | print(r) 19 | 20 | 21 | def test_get_goals(): 22 | r = api.get_goals() 23 | print(r) 24 | 25 | 26 | def test_get_report_mcf_without_transform(): 27 | r = api.get_report(id=104194259, 28 | source='mcf', 29 | date1=datetime(2018, 10, 1), 30 | date2=datetime(2018, 10, 10), 31 | dimensions=['mcf:sourceMediumPath', 'mcf:conversionDate', 'mcf:ConversionType', 'mcf:source'], 32 | metrics=['mcf:totalConversions', 'mcf:totalConversionValue'], 33 | sort='mcf:source', 34 | filters='mcf:ConversionType==Transaction', 35 | is_transform_dataframe=False) 36 | print(r) 37 | 38 | 39 | def test_get_report_mcf(): 40 | r = api.get_report(id=104194259, 41 | source='mcf', 42 | date1=datetime(2018, 10, 1), 43 | date2=datetime(2018, 10, 10), 44 | dimensions=['mcf:sourceMediumPath', 'mcf:conversionDate', 'mcf:ConversionType', 'mcf:source'], 45 | metrics=['mcf:totalConversions', 'mcf:totalConversionValue'], 46 | sort='mcf:source', 47 | filters='mcf:ConversionType==Transaction') 48 | print(r) 49 | 50 | 51 | def test_get_report_ga(): 52 | r = api.get_report(id=108886513, 53 | source='GA', 54 | date1=datetime(2018, 10, 1), 55 | date2=datetime(2018, 10, 10), 56 | dimensions=['ga:date'], 57 | metrics=['ga:percentNewSessions'], 58 | level_group_by_date='date') 59 | print(r) 60 | 61 | 62 | def test_get_report_as_df(): 63 | r = api.get_report(id=108886513, 64 | source='GA', 65 | date1=datetime(2018, 10, 1), 66 | date2=datetime(2018, 10, 10), 67 | dimensions=['ga:date'], 68 | metrics=['ga:percentNewSessions'], 69 | sort='ga:date', 70 | level_group_by_date='date') 71 | print(r) 72 | 73 | 74 | def test_next_page_request(): 75 | r = api.get_report(id=108886513, source='ga', 76 | date1=datetime(2018, 10, 1), 77 | date2=datetime(2018, 10, 10), 78 | dimensions=['ga:date'], 79 | metrics=['ga:percentNewSessions'], 80 | limit=3) 81 | print(r) 82 | 83 | 84 | def test_sampling(): 85 | r = api.get_report(id=130339206, source='ga', 86 | date1=datetime(2018, 1, 1), 87 | date2=datetime(2018, 1, 31), 88 | dimensions=['ga:date', 'ga:userType', 'ga:keyword'], 89 | metrics=['ga:percentNewSessions'], 90 | limit=9000) 91 | print(len(r)) 92 | print(r) 93 | assert len(r.drop_duplicates('ga:date')) == 31 94 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Обертка над стандартной библиотекой google_api_python_client для легкой работы с API Google Analytics v3 2 | 3 | Написано на версии python 3.5 4 | 5 | Умеет запрашивать данные маленькими порциями, 6 | чтобы обойти семплирование. 7 | Также если в один ответ не поместятся все строки (макс 10000 строк), 8 | сделает дополнительные запросы. 9 | 10 | ### Установка 11 | ``` 12 | # Установите эту штуку, она будет генерировать маленькие интервалы в случае семплирования. 13 | # без нее работать не будет 14 | pip install git+https://github.com/pavelmaksimov/daterangepy#egg=daterangepy-2019.4.9 15 | pip install --upgrade git+https://github.com/pavelmaksimov/galytics3 16 | ``` 17 | 18 | ### Как пользоваться 19 | 20 | Указание авторизационных данных. 21 | 22 | Эта обертка не умеет получать токен, он у вас уже должен быть. 23 | Как получить? Гуглите. 24 | 25 | ##### Вариант 1 26 | ```python 27 | from galytics3 import GoogleAnalytics 28 | 29 | api = GoogleAnalytics(refresh_token='{refresh_token}', 30 | client_id='{client_id}', 31 | client_secret='{client_secret}') 32 | ``` 33 | 34 | ##### Вариант 2 35 | Если у вас объект credential создается другим образом. 36 | Через файл или еще как-то. 37 | ```python 38 | from galytics3 import GoogleAnalytics 39 | 40 | credentials = credentials_object # Ваш объект credential 41 | 42 | api = GoogleAnalytics(credentials=credentials) 43 | ``` 44 | 45 | ##### Вариант 3 46 | Объявление дополнительных настроек, типа кеширования. 47 | ```python 48 | from googleapiclient.discovery import build 49 | from galytics3 import GoogleAnalytics 50 | 51 | credentials = credentials_object # Ваш объект credential 52 | # В build можно объявить дополнительные настройки, вроде кеширования и т.д. 53 | service = build('analytics', 'v3', credentials=credentials_object) 54 | api = GoogleAnalytics(service=service) 55 | ``` 56 | 57 | #### Получаем данные 58 | ```python 59 | from datetime import datetime 60 | from galytics3 import GoogleAnalytics 61 | 62 | api = GoogleAnalytics(refresh_token='{refresh_token}', 63 | client_id='{client_id}', 64 | client_secret='{client_secret}') 65 | 66 | # Получит все аккаунты, ресурсы и представления. 67 | df = api.get_accounts(as_dataframe=True) 68 | # По умолчанию данные возвращаются в формате dataframe 69 | print(df) 70 | 71 | # Вернуть в JSON 72 | data = api.get_accounts(as_dataframe=False) 73 | print(data) 74 | 75 | # Получит все цели всех представлений. 76 | df = api.get_goals() 77 | print(df) 78 | 79 | # Запросить стандартный отчет 80 | df = api.get_report( 81 | id=12345789, 82 | source='GA', 83 | date1=datetime(2019, 1, 1), 84 | date2=datetime(2019, 1, 10), 85 | dimensions=['ga:date'], 86 | metrics=['ga:percentNewSessions'], 87 | sort='ga:date') 88 | print(df) 89 | 90 | # Запросить отчет MCF 91 | df = api.get_report( 92 | id=12345789, 93 | source='mcf', 94 | date1=datetime(2019, 1, 1), 95 | date2=datetime(2019, 1, 10), 96 | dimensions=['mcf:sourceMediumPath', 'mcf:conversionDate, mcf:source'], 97 | metrics=['mcf:totalConversions', 'mcf:totalConversionValue'], 98 | sort='mcf:source', 99 | filters='mcf:ConversionType==Transaction') 100 | print(df) 101 | 102 | ``` 103 | 104 | 105 | ## Зависимости 106 | - pandas 107 | - [daterangepy](https://github.com/pavelmaksimov/daterangepy) 108 | 109 | ## Автор 110 | Павел Максимов 111 | 112 | Связаться со мной можно в 113 | [Телеграм](https://t.me/pavel_maksimow) 114 | и в 115 | [Facebook](https://www.facebook.com/pavel.maksimow) 116 | 117 | Удачи тебе, друг! Поставь звездочку ;) 118 | -------------------------------------------------------------------------------- /galytics3/galytics3.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import logging 3 | import re 4 | 5 | import daterangepy 6 | import pandas as pd 7 | from googleapiclient.discovery import build 8 | from oauth2client.client import GoogleCredentials 9 | from pandas.io.json import json_normalize 10 | from .decorators import retry_wrap 11 | 12 | logging.basicConfig(level=logging.INFO) 13 | 14 | 15 | class MaxLevelSamplingError(Exception): 16 | def __init__(self): 17 | super().__init__() 18 | 19 | def __str__(self): 20 | return ( 21 | "Увеличение интервалов для обхода семплирования " 22 | "достигло максимума, до одного." 23 | "Но семплирование все равно есть." 24 | "Разделять периоды по часам, не умею." 25 | ) 26 | 27 | 28 | class GoogleAnalytics: 29 | def __init__( 30 | self, 31 | refresh_token=None, 32 | client_id=None, 33 | client_secret=None, 34 | token_uri="https://www.googleapis.com/oauth2/v3/token", 35 | credentials=None, 36 | service=None, 37 | ): 38 | if service: 39 | self.service = service 40 | else: 41 | if not credentials: 42 | credentials = GoogleCredentials( 43 | refresh_token=refresh_token, 44 | token_uri=token_uri, 45 | client_id=client_id, 46 | client_secret=client_secret, 47 | user_agent="Python client library", 48 | access_token=None, 49 | token_expiry=None, 50 | revoke_uri=None, 51 | ) 52 | 53 | self.service = build( 54 | "analytics", "v3", credentials=credentials, cache_discovery=False 55 | ) 56 | 57 | def _generate_body(self, body, date1, date2, level_group_by_date, delta): 58 | """Создаются несколько конфигов для получения данных.""" 59 | intervals = daterangepy.period_range( 60 | date1, date2, frequency=level_group_by_date, delta=delta 61 | ) 62 | body_list_ = [] 63 | for date in intervals: 64 | body_ = body.copy() 65 | body_["start_date"] = date["date1_str"] 66 | body_["end_date"] = date["date2_str"] 67 | body_list_.append(body_) 68 | 69 | return body_list_ 70 | 71 | def _transform_dataframe(self, df, source): 72 | # Дополнительная обработка, если источник данных MCF. 73 | try: 74 | if source.lower() == "mcf": 75 | # Раскрытие вложенных столбцов. 76 | df = json_normalize(df.to_dict(orient="records")) 77 | # Раскрытие вложенных столбцов. 78 | columns_for_parsing = [ 79 | i for i in df.columns if i.find("conversionPathValue") > -1 80 | ] 81 | df[columns_for_parsing] = df[columns_for_parsing].applymap( 82 | lambda x: x[0] 83 | ) 84 | data_json = df.to_dict(orient="records") 85 | df = json_normalize(data_json) 86 | 87 | df.columns = [ 88 | i.replace(".conversionPathValue.nodeValue", "") for i in df.columns 89 | ] 90 | df.columns = [i.replace(".primitiveValue", "") for i in df.columns] 91 | 92 | # Преобразуется формат даты. Здесь он специфичный. 93 | if "mcf:conversionDate" in df.columns: 94 | df["mcf:conversionDate"] = pd.to_datetime( 95 | df["mcf:conversionDate"] 96 | ).dt.strftime("%Y-%m-%d") 97 | except Exception: 98 | raise Exception( 99 | "Возникла ошибка при трансформации dataframe. " 100 | "Вы можете выключить ее задав is_transform_dataframe=False" 101 | ) 102 | 103 | return df 104 | 105 | def _to_df(self, results_list): 106 | try: 107 | df = pd.DataFrame() 108 | for result in results_list: 109 | columns = [i["name"] for i in result["columnHeaders"]] 110 | df = df.append( 111 | pd.DataFrame(columns=columns, data=result.get("rows", [])) 112 | ) 113 | except Exception: 114 | raise TypeError("Не смог преобразовать в dataframe") 115 | else: 116 | return df.reset_index(drop=True) 117 | 118 | def _get_next_page_body(self, next_page_link, body): 119 | logging.debug("Получены не все данные. Будет сделан еще разпрос") 120 | # Извлечение индекса из ссылки для запроса данных. 121 | # Как сделать запрос через эту ссылку не разобрался. 122 | search = re.search("start-index.[^&]*", next_page_link).group() 123 | next_index = re.sub(r"[^0-9]", "", search) 124 | # Меняется индекс строки от которой будут запрошены данные. 125 | if not next_index: 126 | raise ValueError( 127 | "Не смог извлечь номер строки, " 128 | "от которой запросить следующую пачку данных. " 129 | 'Пытался извлечь параметр start-index из "{}".' 130 | 'И число из значения этого параметра "{}"'.format( 131 | next_page_link, search 132 | ) 133 | ) 134 | body["start_index"] = next_index 135 | return body 136 | 137 | @retry_wrap(retries=3, sleep=5) 138 | def _execute(self, body, source): 139 | if source.lower() == "ga": 140 | request_config = self.service.data().ga().get(**body) 141 | elif source.lower() == "mcf": 142 | request_config = self.service.data().mcf().get(**body) 143 | else: 144 | raise ValueError("Неверное значение source") 145 | 146 | return request_config.execute() 147 | 148 | def _request( 149 | self, body, date1, date2, source, level_group_by_date, max_results=None 150 | ): 151 | """ 152 | Запрашивает данные. 153 | Генерирует новые запросы на ходу, по мере необходимости. 154 | Когда есть семплирование, чтобы обойти его 155 | и когда все данные не поместились в одном ответе 156 | (макс 100к строк в ответе). 157 | """ 158 | body_list = [body] 159 | results_list = [] 160 | sampling_level = 2 # на сколько частей будет делить период 161 | delta = (date2 - date1).days 162 | while body_list: 163 | iter_body = body_list[0] 164 | 165 | result = self._execute(iter_body, source) 166 | 167 | # Сначала проверяем есть ли семплирование по парамтеру в ответе. 168 | # Далее проверяем есть ли семплирование на уровне строк в первой строке. 169 | # И если в первой строке не обнаружено, 170 | # на всяк случай во всех строках проверяется. 171 | if ( 172 | result.get("containsSampledData") 173 | or result.get("rows", [[""]])[0][0] == "(other)" 174 | or [i for i in result.get("rows", [[""]]) if i[0] == "(other)"] 175 | ): 176 | logging.debug("Есть семплирование") 177 | # Кол-во дней в одном интервале. 178 | new_delta = int(delta / (sampling_level)) 179 | new_delta = new_delta + 1 if new_delta > 1 else new_delta 180 | if new_delta < 1: 181 | raise MaxLevelSamplingError 182 | 183 | # Генерируются новые конфиги с меньшим интервалом дат. 184 | body_list = self._generate_body( 185 | body, date1, date2, level_group_by_date, new_delta 186 | ) 187 | # Интервал между датами уменьшется каждый раз в 2 раза. 188 | sampling_level *= 2 189 | results_list.clear() 190 | else: 191 | results_list.append(result) 192 | # Удаляем успешно выполненый конфиг запроса из списка конфигов. 193 | body_list.remove(iter_body) 194 | 195 | # Если получен не все данные, то добавляем еще конфиг, 196 | next_page_link = result.get("nextLink") 197 | next_start_index = result.get("query", {}).get("start-index") 198 | count_results = next_start_index - 1 + body["max_results"] 199 | if next_page_link and (not max_results or max_results > count_results): 200 | next_body = self._get_next_page_body(next_page_link, iter_body) 201 | body_list.append(next_body) 202 | 203 | return results_list 204 | 205 | def get_accounts(self, as_dataframe=True): 206 | """ 207 | Запрашивает всю информацию об аккаунтах, ресурсах и представлениях. 208 | :return: 209 | """ 210 | accounts = [] 211 | 212 | all_accounts = self.service.management().accounts().list().execute() 213 | for account in all_accounts.get("items", []): 214 | 215 | all_webproperties = ( 216 | self.service.management() 217 | .webproperties() 218 | .list(accountId=account["id"]) 219 | .execute() 220 | ) 221 | 222 | for webpropert in all_webproperties.get("items", []): 223 | all_profiles = ( 224 | self.service.management() 225 | .profiles() 226 | .list(accountId=account["id"], webPropertyId=webpropert["id"]) 227 | .execute() 228 | ) 229 | 230 | for profile in all_profiles.get("items", []): 231 | settings_account = all_accounts.copy() 232 | settings_resource = all_webproperties.copy() 233 | settings_view = all_profiles.copy() 234 | 235 | settings_account.pop("items") 236 | settings_resource.pop("items") 237 | settings_view.pop("items") 238 | 239 | data = { 240 | "settings_account": settings_account, 241 | "settings_resource": settings_resource, 242 | "settings_view": settings_view, 243 | "account": account, 244 | "resource": webpropert, 245 | "view": profile, 246 | } 247 | accounts.append(data) 248 | 249 | return json_normalize(accounts) if as_dataframe else accounts 250 | 251 | def get_goals(self, as_dataframe=True): 252 | """Запрашивает все цели всех представлений аккаунта.""" 253 | df_accounts = self.get_accounts() 254 | ids_list = ( 255 | df_accounts[["account.id", "resource.id", "view.id"]] 256 | .drop_duplicates() 257 | .to_dict(orient="records") 258 | ) 259 | 260 | result = [] 261 | for i in ids_list: 262 | result_ = ( 263 | self.service.management() 264 | .goals() 265 | .list( 266 | accountId=i["account.id"], 267 | webPropertyId=i["resource.id"], 268 | profileId=i["view.id"], 269 | ) 270 | .execute() 271 | ) 272 | result += result_.get("items", []) 273 | 274 | return json_normalize(result) if as_dataframe else result 275 | 276 | def get_report( 277 | self, 278 | id, 279 | source, 280 | date1, 281 | date2, 282 | dimensions, 283 | metrics, 284 | sort=None, 285 | filters=None, 286 | limit=10000, 287 | max_results=None, 288 | level_group_by_date="date", 289 | as_dataframe=True, 290 | is_transform_dataframe=True, 291 | ): 292 | """ 293 | 294 | :param id: int, str : идентификатор аккаунта, например "123456789" 295 | :param source: str : ga|mcf 296 | :param date1: datetime 297 | :param date2: datetime 298 | :param metrics: str, list 299 | :param dimensions: str, list 300 | :param sort: str 301 | :param filters: str 302 | :param limit: int, максимальное кол-во строк в одном запросе. Должно быть обязательно <= 10000. 303 | :param max_results: int, максимальное кол-во строк в отчете 304 | :param level_group_by_date: str : day|date|week|month|quarter|year 305 | :param as_dataframe: bool : возвращать ли в формате dataframe 306 | :param is_transform_dataframe: bool : трансформировать dataframe 307 | :return: [..., '{данные ответа}'], dataframe 308 | """ 309 | source = source.lower() 310 | if source not in ("ga", "mcf"): 311 | raise ValueError("Неизвестный источник данных {}".format(source)) 312 | 313 | if limit > 10000: 314 | raise ValueError("Параметр limit должнен быть обязательно <= 10000") 315 | 316 | if isinstance(metrics, list): 317 | metrics = ",".join(map(str, metrics)) 318 | if isinstance(dimensions, list): 319 | dimensions = ",".join(map(str, dimensions)) 320 | 321 | body = dict( 322 | ids="ga:{}".format(id), 323 | start_date=str(date1.date()), 324 | end_date=str(date2.date()), 325 | metrics=metrics, 326 | dimensions=dimensions, 327 | start_index="1", 328 | samplingLevel="HIGHER_PRECISION", 329 | ) 330 | if limit: 331 | body["max_results"] = limit 332 | elif max_results: 333 | body["max_results"] = max_results 334 | 335 | if sort: 336 | body["sort"] = sort 337 | if filters: 338 | body["filters"] = filters 339 | 340 | results_list = self._request( 341 | body, date1, date2, source, level_group_by_date, max_results=max_results 342 | ) 343 | 344 | if as_dataframe: 345 | df = self._to_df(results_list) 346 | if is_transform_dataframe: 347 | df = self._transform_dataframe(df, source) 348 | return df 349 | else: 350 | return results_list 351 | --------------------------------------------------------------------------------