From b3f28cb0c4fec1da1591afe1b78ed044a67dd968 Mon Sep 17 00:00:00 2001 From: Domenico Iezzi Date: Tue, 17 Oct 2017 11:41:39 +0200 Subject: [PATCH] Download optional expansion files (obb files) Signed-off-by: Domenico Iezzi --- .gitignore | 1 + gpapi/googleplay.py | 99 +++++++++++++++++++++++++++++++++----------- obb_download_test.py | 25 +++++++++++ test.py | 6 +-- 4 files changed, 103 insertions(+), 28 deletions(-) create mode 100644 obb_download_test.py diff --git a/.gitignore b/.gitignore index dee7083..c34c42e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ __pycache__/ *.swp *.apk +*.obb build/ dist/ *.egg-info/ diff --git a/gpapi/googleplay.py b/gpapi/googleplay.py index 6ef2a67..f0deaf6 100644 --- a/gpapi/googleplay.py +++ b/gpapi/googleplay.py @@ -7,9 +7,10 @@ from Crypto.Hash import SHA from Crypto.Cipher import PKCS1_OAEP from clint.textui import progress +from os.path import splitext import requests import base64 -import itertools +from itertools import chain from . import googleplay_pb2, config, utils @@ -287,7 +288,7 @@ class GooglePlayAPI(object): nextPath = cluster.doc[0].containerMetadata.nextPageUrl else: nextPath = None - apps = list(itertools.chain.from_iterable([doc.child for doc in cluster.doc])) + apps = list(chain.from_iterable([doc.child for doc in cluster.doc])) output += list(map(utils.fromDocToDictionary, apps)) remaining -= len(apps) @@ -392,9 +393,19 @@ class GooglePlayAPI(object): def reviews(self, packageName, filterByDevice=False, sort=2, nb_results=None, offset=None): - """Browse reviews. - packageName is the app unique ID. - If filterByDevice is True, return only reviews for your device.""" + """Browse reviews for an application + + Args: + packageName (str): app unique ID. + filterByDevice (bool): filter results for current device + sort (int): sorting criteria (values are unknown) + nb_results (int): max number of reviews to return + offset (int): return reviews starting from an offset value + + Returns: + dict object containing all the protobuf data returned from + the api + """ path = "rev?doc=%s&sort=%d" % (requests.utils.quote(packageName), sort) if (nb_results is not None): path += "&n=%d" % int(nb_results) @@ -419,14 +430,44 @@ class GooglePlayAPI(object): output.append(review) return output + def _deliver_data(self, url, cookies, progress_bar): + headers = self.getDefaultHeaders() + if not progress_bar: + return requests.get(url, headers=headers, + cookies=cookies, verify=ssl_verify).content + response_content = bytes() + response = requests.get(url, headers=headers, cookies=cookies, verify=ssl_verify, stream=True) + total_length = int(response.headers.get('content-length')) + chunk_size = 32 * (1<<10) # 32 KB + bar = progress.Bar(expected_size=(total_length >> 10)) + for index, chunk in enumerate(response.iter_content(chunk_size=chunk_size)): + response_content += chunk + bar.show(index * chunk_size >> 10) + bar.done() + return response_content + def delivery(self, packageName, versionCode, offerType=1, downloadToken=None, progress_bar=False): """Download an already purchased app. - packageName is the app unique ID (usually starting with 'com.'). + Args: + packageName (str): app unique ID (usually starting with 'com.') + versionCode (int): version to download + offerType (int): different type of downloads (mostly unused for apks) + downloadToken (str): download token returned by 'purchase' API + progress_bar (bool): wether or not to print a progress bar to stdout - versionCode can be grabbed by using the details() method on the given - app.""" + Returns: + Dictionary containing apk data and a list of expansion files. As stated + in android documentation, there can be at most 2 expansion files, one with + main content, and one for patching the main content. Their names should + follow this format: + + [main|patch]...obb + + Data to build this name string is provided in the dict object. For more + info check https://developer.android.com/google/play/expansion-files.html + """ path = "delivery" params = {'ot': str(offerType), 'doc': packageName, @@ -443,25 +484,28 @@ class GooglePlayAPI(object): elif resObj.payload.deliveryResponse.appDeliveryData.downloadUrl == "": raise RequestError('App not purchased') else: + result = {} + result['docId'] = packageName + result['additionalData'] = [] downloadUrl = resObj.payload.deliveryResponse.appDeliveryData.downloadUrl cookie = resObj.payload.deliveryResponse.appDeliveryData.downloadAuthCookie[0] cookies = { str(cookie.name): str(cookie.value) } - if not progress_bar: - return requests.get(downloadUrl, headers=headers, - cookies=cookies, verify=ssl_verify).content + result['data'] = self._deliver_data(downloadUrl, cookies, progress_bar) + count = 1 + for obb in resObj.payload.deliveryResponse.appDeliveryData.additionalFile: + a = {} + if obb.fileType == 0: + obbType = 'main' + else: + obbType = 'patch' + a['type'] = obbType + a['versionCode'] = obb.versionCode + a['data'] = self._deliver_data(obb.downloadUrl, None, progress_bar) + result['additionalData'].append(a) + return result - response_content = bytes() - response = requests.get(downloadUrl, headers=headers, cookies=cookies, verify=ssl_verify, stream=True) - total_length = int(response.headers.get('content-length')) - chunk_size = 32 * (1<<10) # 32 KB - bar = progress.Bar(expected_size=(total_length >> 10)) - for index, chunk in enumerate(response.iter_content(chunk_size=chunk_size)): - response_content += chunk - bar.show(index * chunk_size >> 10) - bar.done() - return response_content def download(self, packageName, versionCode, offerType=1, progress_bar=False): @@ -469,10 +513,17 @@ class GooglePlayAPI(object): to be "purchased" first, in order to retrieve the download cookie. If you want to download an already purchased app, use *delivery* method. - packageName is the app unique ID (usually starting with 'com.'). + Args: + packageName (str): app unique ID (usually starting with 'com.') + versionCode (int): version to download + offerType (int): different type of downloads (mostly unused for apks) + downloadToken (str): download token returned by 'purchase' API + progress_bar (bool): wether or not to print a progress bar to stdout - versionCode can be grabbed by using the details() method on the given - app.""" + Returns + Dictionary containing apk data and optional expansion files + (see *delivery*) + """ if self.authSubToken is None: raise Exception("You need to login before executing any request") diff --git a/obb_download_test.py b/obb_download_test.py new file mode 100644 index 0000000..1ed7b27 --- /dev/null +++ b/obb_download_test.py @@ -0,0 +1,25 @@ +from gpapi.googleplay import GooglePlayAPI, RequestError + +import sys + +EMAIL = "dodo.godlike" +PASSWD = "inpobgakicfmnhwc" + +testApps = ['com.cpuid.cpu_z'] +server = GooglePlayAPI(debug=True) + +# LOGIN + +print('\nLogging in with email and password\n') +server.login(EMAIL, PASSWD, None, None) + +download = server.download('com.haikugamesco.escapeasylum', 21, progress_bar=True) +with open(download['docId'] + '.apk', 'wb') as first: + first.write(download['data']) + print('\nDownload successful\n') + +for obb in download['additionalData']: + name = obb['type'] + '.' + str(obb['versionCode']) + '.' + download['docId'] + '.obb' + with open(name, 'wb') as second: + second.write(obb['data']) + print('\nDownloaded additional data\n') diff --git a/test.py b/test.py index 03fac5d..26694ac 100644 --- a/test.py +++ b/test.py @@ -37,9 +37,8 @@ print('\nTelegram docid is: %s\n' % docid) print('\nAttempting to download %s\n' % docid) fl = server.download(docid, version, progress_bar=True) with open(docid + '.apk', 'wb') as f: - f.write(fl) + f.write(fl['data']) print('\nDownload successful\n') - f.close() # DOWNLOAD APP NOT PURCHASED # Attempting to download Nova Launcher Prime @@ -53,9 +52,8 @@ try: app = list(app)[0] fl = server.delivery(app['docId'], app['versionCode'], progress_bar=True) with open(docid + '.apk', 'wb') as f: - f.write(fl) + f.write(fl['data']) print('\nDownload successful\n') - f.close() except RequestError as e: errorThrown = True print(e)