diff --git a/gpapi/config.py b/gpapi/config.py index 24d4df3..46f6b62 100644 --- a/gpapi/config.py +++ b/gpapi/config.py @@ -141,7 +141,8 @@ def getLoginParams(self, email, encrypted_passwd): "device_country": self.locale[0:2], "lang": self.locale, "client_sig": "38918a453d07199354f8b19af05ec6562ced5788", - "callerSig": "38918a453d07199354f8b19af05ec6562ced5788"} + "callerSig": "38918a453d07199354f8b19af05ec6562ced5788", + "droidguard_results": "dummy123"} def getAndroidCheckinRequest(self): request = googleplay_pb2.AndroidCheckinRequest() diff --git a/gpapi/googleplay.py b/gpapi/googleplay.py index 60c6ca6..a504ca0 100644 --- a/gpapi/googleplay.py +++ b/gpapi/googleplay.py @@ -10,6 +10,10 @@ from cryptography.hazmat.primitives.asymmetric import padding import requests +import ssl + +from urllib3.poolmanager import PoolManager +from urllib3.util import ssl_ from . import googleplay_pb2, config, utils @@ -39,6 +43,27 @@ CONTENT_TYPE_PROTO = "application/x-protobuf" +class SSLContext(ssl.SSLContext): + def set_alpn_protocols(self, protocols): + """ + ALPN headers cause Google to return 403 Bad Authentication. + """ + pass + +class AuthHTTPAdapter(requests.adapters.HTTPAdapter): + def init_poolmanager(self, *args, **kwargs): + """ + Secure settings from ssl.create_default_context(), but without + ssl.OP_NO_TICKET which causes Google to return 403 Bad + Authentication. + """ + context = SSLContext() + context.set_ciphers(ssl_.DEFAULT_CIPHERS) + context.verify_mode = ssl.CERT_REQUIRED + context.options &= ~0x4000 + self.poolmanager = PoolManager(*args, ssl_context=context, **kwargs) + + class LoginError(Exception): def __init__(self, value): self.value = value @@ -64,7 +89,6 @@ def __str__(self): class GooglePlayAPI(object): """Google Play Unofficial API Class - Usual APIs methods are login(), search(), details(), bulkDetails(), download(), browse(), reviews() and list().""" @@ -79,6 +103,8 @@ def __init__(self, locale="en_US", timezone="UTC", device_codename="bacon", self.deviceBuilder = config.DeviceBuilder(device_codename) self.setLocale(locale) self.setTimezone(timezone) + self.session = requests.session() + self.session.mount('https://', AuthHTTPAdapter()) def setLocale(self, locale): self.deviceBuilder.setLocale(locale) @@ -157,7 +183,7 @@ def checkin(self, email, ac2dmToken): request = self.deviceBuilder.getAndroidCheckinRequest() stringRequest = request.SerializeToString() - res = requests.post(CHECKIN_URL, data=stringRequest, + res = self.session.post(CHECKIN_URL, data=stringRequest, headers=headers, verify=ssl_verify, proxies=self.proxies_config) response = googleplay_pb2.AndroidCheckinResponse() @@ -170,7 +196,7 @@ def checkin(self, email, ac2dmToken): request.accountCookie.append("[" + email + "]") request.accountCookie.append(ac2dmToken) stringRequest = request.SerializeToString() - requests.post(CHECKIN_URL, + self.session.post(CHECKIN_URL, data=stringRequest, headers=headers, verify=ssl_verify, @@ -186,7 +212,7 @@ def uploadDeviceConfig(self): upload.deviceConfiguration.CopyFrom(self.deviceBuilder.getDeviceConfig()) headers = self.getHeaders(upload_fields=True) stringRequest = upload.SerializeToString() - response = requests.post(UPLOAD_URL, data=stringRequest, + response = self.session.post(UPLOAD_URL, data=stringRequest, headers=headers, verify=ssl_verify, timeout=60, @@ -199,7 +225,7 @@ def uploadDeviceConfig(self): except ValueError: pass - def login(self, email=None, password=None, gsfId=None, authSubToken=None): + def login(self, email=None, password=None, gsfId=None, authSubToken=None, returnParams=False): """Login to your Google Account. For first time login you should provide: * email @@ -217,10 +243,12 @@ def login(self, email=None, password=None, gsfId=None, authSubToken=None): params['service'] = 'ac2dm' params['add_account'] = '1' params['callerPkg'] = 'com.google.android.gms' - headers = self.deviceBuilder.getAuthHeaders(self.gsfId) - headers['app'] = 'com.google.android.gsm' - response = requests.post(AUTH_URL, data=params, verify=ssl_verify, - proxies=self.proxies_config) + with requests.Session() as s: + s.headers = {'User-Agent': 'GoogleAuth/1.4'} + response = s.post(AUTH_URL, + data=params, + verify=ssl_verify, + proxies=self.proxies_config) data = response.text.split() params = {} for d in data: @@ -232,9 +260,13 @@ def login(self, email=None, password=None, gsfId=None, authSubToken=None): ac2dmToken = params["auth"] elif "error" in params: if "NeedsBrowser" in params["error"]: + # This callback is returned, but it doesn't appear to + # actually work for getting into your account + callback_url = params.get("url", None) raise SecurityCheckError("Security check is needed, try to visit " "https://accounts.google.com/b/0/DisplayUnlockCaptcha " - "to unlock, or setup an app-specific password") + "to unlock, or setup an app-specific password. " + f"Callback URL: {callback_url}") raise LoginError("server says: " + params["error"]) else: raise LoginError("Auth token not found.") @@ -242,6 +274,8 @@ def login(self, email=None, password=None, gsfId=None, authSubToken=None): self.gsfId = self.checkin(email, ac2dmToken) self.getAuthSubToken(email, encryptedPass) self.uploadDeviceConfig() + if returnParams: + return self.gsfId, self.authSubToken elif gsfId is not None and authSubToken is not None: # no need to initialize API self.gsfId = gsfId @@ -255,13 +289,12 @@ def getAuthSubToken(self, email, passwd): requestParams = self.deviceBuilder.getLoginParams(email, passwd) requestParams['service'] = 'androidmarket' requestParams['app'] = 'com.android.vending' - headers = self.deviceBuilder.getAuthHeaders(self.gsfId) - headers['app'] = 'com.android.vending' - response = requests.post(AUTH_URL, - data=requestParams, - verify=ssl_verify, - headers=headers, - proxies=self.proxies_config) + with requests.Session() as s: + s.headers = {'User-Agent': 'GoogleAuth/1.4', 'device':"{0:x}".format(self.gsfId)} + response = s.post(AUTH_URL, + data=requestParams, + verify=ssl_verify, + proxies=self.proxies_config) data = response.text.split() params = {} for d in data: @@ -290,7 +323,7 @@ def getSecondRoundToken(self, first_token, params): params.pop('EncryptedPasswd') headers = self.deviceBuilder.getAuthHeaders(self.gsfId) headers['app'] = 'com.android.vending' - response = requests.post(AUTH_URL, + response = self.session.post(AUTH_URL, data=params, headers=headers, verify=ssl_verify, @@ -316,7 +349,7 @@ def executeRequestApi2(self, path, post_data=None, content_type=CONTENT_TYPE_URL headers["Content-Type"] = content_type if post_data is not None: - response = requests.post(path, + response = self.session.post(path, data=str(post_data), headers=headers, params=params, @@ -324,7 +357,7 @@ def executeRequestApi2(self, path, post_data=None, content_type=CONTENT_TYPE_URL timeout=60, proxies=self.proxies_config) else: - response = requests.get(path, + response = self.session.get(path, headers=headers, params=params, verify=ssl_verify, @@ -348,9 +381,7 @@ def searchSuggest(self, query): def search(self, query): """ Search the play store for an app. - nb_result (int): is the maximum number of result to be returned - offset (int): is used to take result starting from an index. """ if self.authSubToken is None: @@ -367,24 +398,24 @@ def search(self, query): resIterator = response.payload.listResponse.doc return list(map(utils.parseProtobufObj, resIterator)) - def details(self, packageName): + def details(self, packageName, versionCode=False): """Get app details from a package name. - - packageName is the app unique ID (usually starting with 'com.').""" - path = DETAILS_URL + "?doc={}".format(requests.utils.quote(packageName)) + packageName (str) is the app unique ID (usually starting with 'com.'). + versionCode (int) is the version code desired.""" + if versionCode: + path = DETAILS_URL + "?doc={}&vc={}".format(requests.utils.quote(packageName), requests.utils.quote(str(versionCode))) + else: + path = DETAILS_URL + "?doc={}".format(requests.utils.quote(packageName)) data = self.executeRequestApi2(path) return utils.parseProtobufObj(data.payload.detailsResponse.docV2) def bulkDetails(self, packageNames): """Get several apps details from a list of package names. - This is much more efficient than calling N times details() since it requires only one request. If an item is not found it returns an empty object instead of throwing a RequestError('Item not found') like the details() function - Args: packageNames (list): a list of app IDs (usually starting with 'com.'). - Returns: a list of dictionaries containing docv2 data, or None if the app doesn't exist""" @@ -427,10 +458,39 @@ def browse(self, cat=None, subCat=None): return utils.parseProtobufObj(data.payload.browseResponse) + def list_ranks(self, cat, ctr, next_page_url=None): + """ + List top ranks for the given category and rank list. + Args: + cat (str) - Category ID. + ctr (str) - Rank list ID. + nb_results (int) - Number of results per request. + next_page_url (str) - Next page url for subsequent self.session. + Returns: + (a list of apps, next page url) + """ + if next_page_url: + path = FDFE + next_page_url + else: + path = LIST_URL + "?c=3&scat={}".format(requests.utils.quote(cat)) + path += "&stcid={}".format(requests.utils.quote(ctr)) + + data = self.executeRequestApi2(path) + apps = [] + for d in data.payload.listResponse.doc: # categories + for c in d.child: # sub-category + for a in c.child: # app + apps.append(utils.parseProtobufObj(a)) + try: + # Sometimes we get transient very short response which indicates there's no more data + next_page_url = data.payload.listResponse.doc[0].child[0].containerMetadata.nextPageUrl + except Exception: + return (apps, "") + return (apps, next_page_url) + def list(self, cat, ctr=None, nb_results=None, offset=None): """List all possible subcategories for a specific category. If also a subcategory is provided, list apps from this category. - Args: cat (str): category id ctr (str): subcategory id @@ -469,14 +529,12 @@ def list(self, cat, ctr=None, nb_results=None, offset=None): def reviews(self, packageName, filterByDevice=False, sort=2, nb_results=None, offset=None): """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 @@ -497,7 +555,7 @@ def reviews(self, packageName, filterByDevice=False, sort=2, def _deliver_data(self, url, cookies): headers = self.getHeaders() - response = requests.get(url, headers=headers, + response = self.session.get(url, headers=headers, cookies=cookies, verify=ssl_verify, stream=True, timeout=60, proxies=self.proxies_config) @@ -508,24 +566,20 @@ def _deliver_data(self, url, cookies): 'chunk_size': chunk_size} def delivery(self, packageName, versionCode=None, offerType=1, - downloadToken=None, expansion_files=False): + downloadToken=None, expansion_files=False, versionString=None): """Download an already purchased app. - 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 - 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 """ @@ -534,6 +588,14 @@ def delivery(self, packageName, versionCode=None, offerType=1, # pick up latest version appDetails = self.details(packageName).get('details').get('appDetails') versionCode = appDetails.get('versionCode') + versionString = appDetails.get('versionString') + + if versionString is None: + # pick up latest version + appDetails = self.details(packageName).get('details').get('appDetails') + versionString = appDetails.get('versionString') + + params = {'ot': str(offerType), 'doc': packageName, @@ -541,7 +603,7 @@ def delivery(self, packageName, versionCode=None, offerType=1, headers = self.getHeaders() if downloadToken is not None: params['dtok'] = downloadToken - response = requests.get(DELIVERY_URL, headers=headers, + response = self.session.get(DELIVERY_URL, headers=headers, params=params, verify=ssl_verify, timeout=60, proxies=self.proxies_config) @@ -588,14 +650,12 @@ def download(self, packageName, versionCode=None, offerType=1, expansion_files=F """Download an app and return its raw data (APK file). Free apps need to be "purchased" first, in order to retrieve the download cookie. If you want to download an already purchased app, use *delivery* method. - 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 - Returns Dictionary containing apk data and optional expansion files (see *delivery*) @@ -613,8 +673,8 @@ def download(self, packageName, versionCode=None, offerType=1, expansion_files=F params = {'ot': str(offerType), 'doc': packageName, 'vc': str(versionCode)} - self.log(packageName) - response = requests.post(PURCHASE_URL, headers=headers, + # self.log(packageName) + response = self.session.post(PURCHASE_URL, headers=headers, params=params, verify=ssl_verify, timeout=60, proxies=self.proxies_config) @@ -634,7 +694,7 @@ def log(self, docid): log_request.timestamp = timestamp string_request = log_request.SerializeToString() - response = requests.post(LOG_URL, + response = self.session.post(LOG_URL, data=string_request, headers=self.getHeaders(), verify=ssl_verify, @@ -645,7 +705,7 @@ def log(self, docid): raise RequestError(response.commands.displayErrorMessage) def toc(self): - response = requests.get(TOC_URL, + response = self.session.get(TOC_URL, headers=self.getHeaders(), verify=ssl_verify, timeout=60, @@ -664,7 +724,7 @@ def acceptTos(self, tosToken): "tost": tosToken, "toscme": "false" } - response = requests.get(ACCEPT_TOS_URL, + response = self.session.get(ACCEPT_TOS_URL, headers=self.getHeaders(), params=params, verify=ssl_verify, @@ -679,4 +739,4 @@ def getDevicesCodenames(): @staticmethod def getDevicesReadableNames(): - return config.getDevicesReadableNames() + return config.getDevicesReadableNames() \ No newline at end of file