2017-08-27 15:32:05 +00:00
|
|
|
#!/usr/bin/python
|
|
|
|
|
|
|
|
|
2017-09-15 20:11:44 +00:00
|
|
|
from Crypto.Util import asn1
|
|
|
|
from Crypto.PublicKey import RSA
|
|
|
|
from Crypto.Hash import SHA
|
|
|
|
from Crypto.Cipher import PKCS1_OAEP
|
2017-10-07 16:59:23 +00:00
|
|
|
from clint.textui import progress
|
2017-08-27 15:32:05 +00:00
|
|
|
|
2017-09-18 14:33:11 +00:00
|
|
|
import requests
|
2017-11-07 13:02:23 +00:00
|
|
|
from base64 import b64decode, urlsafe_b64encode
|
2017-10-17 09:41:39 +00:00
|
|
|
from itertools import chain
|
2017-08-27 15:32:05 +00:00
|
|
|
|
2017-09-21 09:19:48 +00:00
|
|
|
from . import googleplay_pb2, config, utils
|
2017-09-18 14:33:11 +00:00
|
|
|
|
2017-08-27 15:32:05 +00:00
|
|
|
ssl_verify = True
|
|
|
|
|
|
|
|
|
|
|
|
class LoginError(Exception):
|
|
|
|
def __init__(self, value):
|
|
|
|
self.value = value
|
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
return repr(self.value)
|
|
|
|
|
|
|
|
|
|
|
|
class RequestError(Exception):
|
|
|
|
def __init__(self, value):
|
|
|
|
self.value = value
|
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
return repr(self.value)
|
|
|
|
|
|
|
|
|
|
|
|
class GooglePlayAPI(object):
|
|
|
|
"""Google Play Unofficial API Class
|
|
|
|
|
|
|
|
Usual APIs methods are login(), search(), details(), bulkDetails(),
|
2017-09-16 17:12:06 +00:00
|
|
|
download(), browse(), reviews() and list()."""
|
2017-08-27 15:32:05 +00:00
|
|
|
|
2017-09-17 10:29:42 +00:00
|
|
|
BASE = "https://android.clients.google.com/"
|
|
|
|
FDFE = BASE + "fdfe/"
|
|
|
|
UPLOADURL = FDFE + "uploadDeviceConfig"
|
|
|
|
SEARCHURL = FDFE + "search"
|
|
|
|
CHECKINURL = BASE + "checkin"
|
|
|
|
AUTHURL = BASE + "auth"
|
2017-08-27 15:32:05 +00:00
|
|
|
|
2017-11-10 21:25:33 +00:00
|
|
|
def __init__(self, debug=False, device_codename='bacon',
|
|
|
|
locale=None, timezone=None,
|
|
|
|
sim_operator=None, cell_operator=None):
|
2017-10-12 17:46:20 +00:00
|
|
|
self.authSubToken = None
|
|
|
|
self.gsfId = None
|
2017-08-27 15:32:05 +00:00
|
|
|
self.debug = debug
|
2017-10-13 17:12:06 +00:00
|
|
|
self.deviceBuilder = config.DeviceBuilder(device_codename)
|
2017-11-13 10:44:15 +00:00
|
|
|
self.deviceBuilder.setLocale(locale)
|
2017-11-10 21:25:33 +00:00
|
|
|
if timezone is not None:
|
|
|
|
self.deviceBuilder.timezone = timezone
|
|
|
|
if sim_operator is not None:
|
|
|
|
self.deviceBuilder.device['simoperator'] = sim_operator
|
|
|
|
if cell_operator is not None:
|
|
|
|
self.deviceBuilder.device['celloperator'] = cell_operator
|
2017-11-03 10:35:50 +00:00
|
|
|
# save last response text for error logging
|
|
|
|
self.lastResponseText = None
|
2017-08-27 15:32:05 +00:00
|
|
|
|
2017-09-08 14:26:37 +00:00
|
|
|
def encrypt_password(self, login, passwd):
|
2017-09-17 10:29:42 +00:00
|
|
|
"""Encrypt the password using the google publickey, using
|
|
|
|
the RSA encryption algorithm"""
|
2017-09-08 14:26:37 +00:00
|
|
|
|
2017-11-07 13:02:23 +00:00
|
|
|
binaryKey = b64decode(config.GOOGLE_PUBKEY)
|
2017-09-18 14:33:11 +00:00
|
|
|
i = utils.readInt(binaryKey, 0)
|
|
|
|
modulus = utils.toBigInt(binaryKey[4:][0:i])
|
|
|
|
j = utils.readInt(binaryKey, i+4)
|
|
|
|
exponent = utils.toBigInt(binaryKey[i+8:][0:j])
|
2017-09-15 20:11:44 +00:00
|
|
|
|
|
|
|
seq = asn1.DerSequence()
|
|
|
|
seq.append(modulus)
|
|
|
|
seq.append(exponent)
|
|
|
|
|
|
|
|
publicKey = RSA.importKey(seq.encode())
|
|
|
|
cipher = PKCS1_OAEP.new(publicKey)
|
|
|
|
combined = login.encode() + b'\x00' + passwd.encode()
|
|
|
|
encrypted = cipher.encrypt(combined)
|
|
|
|
h = b'\x00' + SHA.new(binaryKey).digest()[0:4]
|
2017-11-07 13:02:23 +00:00
|
|
|
return urlsafe_b64encode(h + encrypted)
|
2017-09-08 14:26:37 +00:00
|
|
|
|
2017-08-27 15:32:05 +00:00
|
|
|
def setAuthSubToken(self, authSubToken):
|
|
|
|
self.authSubToken = authSubToken
|
|
|
|
|
|
|
|
# put your auth token in config.py to avoid multiple login requests
|
|
|
|
if self.debug:
|
|
|
|
print("authSubToken: " + authSubToken)
|
|
|
|
|
2017-09-15 20:11:44 +00:00
|
|
|
def getDefaultHeaders(self):
|
2017-09-17 10:29:42 +00:00
|
|
|
"""Return the default set of request headers, which
|
2017-11-07 13:02:23 +00:00
|
|
|
can later be expanded, based on the request type"""
|
2017-09-15 20:11:44 +00:00
|
|
|
|
2017-11-07 13:02:23 +00:00
|
|
|
headers = {"Accept-Language": self.deviceBuilder.locale.replace('_', '-'),
|
|
|
|
"X-DFE-Encoded-Targets": config.DFE_TARGETS,
|
|
|
|
"User-Agent": self.deviceBuilder.getUserAgent()}
|
2017-09-15 20:11:44 +00:00
|
|
|
if self.gsfId is not None:
|
|
|
|
headers["X-DFE-Device-Id"] = "{0:x}".format(self.gsfId)
|
|
|
|
if self.authSubToken is not None:
|
|
|
|
headers["Authorization"] = "GoogleLogin auth=%s" % self.authSubToken
|
|
|
|
return headers
|
|
|
|
|
2017-10-13 17:12:06 +00:00
|
|
|
def checkin(self, email, ac2dmToken):
|
2017-09-15 20:11:44 +00:00
|
|
|
headers = self.getDefaultHeaders()
|
|
|
|
headers["Content-Type"] = "application/x-protobuffer"
|
|
|
|
|
2017-10-13 17:12:06 +00:00
|
|
|
request = self.deviceBuilder.getAndroidCheckinRequest()
|
2017-09-15 20:11:44 +00:00
|
|
|
|
|
|
|
stringRequest = request.SerializeToString()
|
2017-09-17 10:29:42 +00:00
|
|
|
res = requests.post(self.CHECKINURL, data=stringRequest,
|
|
|
|
headers=headers, verify=ssl_verify)
|
2017-09-15 20:11:44 +00:00
|
|
|
response = googleplay_pb2.AndroidCheckinResponse()
|
|
|
|
response.ParseFromString(res.content)
|
|
|
|
|
|
|
|
# checkin again to upload gfsid
|
|
|
|
request2 = googleplay_pb2.AndroidCheckinRequest()
|
|
|
|
request2.CopyFrom(request)
|
|
|
|
request2.id = response.androidId
|
|
|
|
request2.securityToken = response.securityToken
|
|
|
|
request2.accountCookie.append("[" + email + "]")
|
2017-10-04 09:32:56 +00:00
|
|
|
request2.accountCookie.append(ac2dmToken)
|
2017-09-15 20:11:44 +00:00
|
|
|
stringRequest = request2.SerializeToString()
|
2017-10-10 16:35:40 +00:00
|
|
|
requests.post(self.CHECKINURL, data=stringRequest,
|
|
|
|
headers=headers, verify=ssl_verify)
|
2017-09-15 20:11:44 +00:00
|
|
|
|
|
|
|
return response.androidId
|
|
|
|
|
|
|
|
def uploadDeviceConfig(self):
|
2017-11-07 13:02:23 +00:00
|
|
|
"""Upload the device configuration of the fake device
|
|
|
|
selected in the __init__ methodi to the google account."""
|
2017-09-17 10:29:42 +00:00
|
|
|
|
2017-09-15 20:11:44 +00:00
|
|
|
upload = googleplay_pb2.UploadDeviceConfigRequest()
|
2017-10-13 17:12:06 +00:00
|
|
|
upload.deviceConfiguration.CopyFrom(self.deviceBuilder.getDeviceConfig())
|
2017-09-15 20:11:44 +00:00
|
|
|
headers = self.getDefaultHeaders()
|
|
|
|
headers["X-DFE-Enabled-Experiments"] = "cl:billing.select_add_instrument_by_default"
|
|
|
|
headers["X-DFE-Unsupported-Experiments"] = "nocache:billing.use_charging_poller,market_emails,buyer_currency,prod_baseline,checkin.set_asset_paid_app_field,shekel_test,content_ratings,buyer_currency_in_app,nocache:encrypted_apk,recent_changes"
|
|
|
|
headers["X-DFE-Client-Id"] = "am-android-google"
|
|
|
|
headers["X-DFE-SmallestScreenWidthDp"] = "320"
|
|
|
|
headers["X-DFE-Filter-Level"] = "3"
|
|
|
|
stringRequest = upload.SerializeToString()
|
2017-09-17 10:29:42 +00:00
|
|
|
res = requests.post(self.UPLOADURL, data=stringRequest,
|
2017-09-15 20:11:44 +00:00
|
|
|
headers=headers, verify=ssl_verify)
|
2017-10-10 16:35:40 +00:00
|
|
|
googleplay_pb2.ResponseWrapper.FromString(res.content)
|
2017-09-15 20:11:44 +00:00
|
|
|
|
2017-10-13 17:12:06 +00:00
|
|
|
def login(self, email=None, password=None, gsfId=None, authSubToken=None):
|
2017-09-16 11:17:21 +00:00
|
|
|
"""Login to your Google Account.
|
|
|
|
For first time login you should provide:
|
|
|
|
* email
|
|
|
|
* password
|
2017-10-04 09:32:56 +00:00
|
|
|
For the following logins you need to provide:
|
|
|
|
* gsfId
|
|
|
|
* authSubToken"""
|
|
|
|
if email is not None and password is not None:
|
2017-09-16 11:17:21 +00:00
|
|
|
# First time setup, where we obtain an ac2dm token and
|
|
|
|
# upload device information
|
2017-09-15 20:11:44 +00:00
|
|
|
|
2017-10-04 09:32:56 +00:00
|
|
|
encryptedPass = self.encrypt_password(email, password).decode('utf-8')
|
2017-09-15 20:11:44 +00:00
|
|
|
# AC2DM token
|
2017-10-19 15:51:03 +00:00
|
|
|
params = self.deviceBuilder.getLoginParams(email, encryptedPass)
|
2017-09-17 10:29:42 +00:00
|
|
|
response = requests.post(self.AUTHURL, data=params, verify=ssl_verify)
|
2017-11-03 10:35:50 +00:00
|
|
|
if self.debug:
|
|
|
|
self.lastResponseText = response.text
|
2017-08-27 15:32:05 +00:00
|
|
|
data = response.text.split()
|
|
|
|
params = {}
|
|
|
|
for d in data:
|
|
|
|
if "=" not in d:
|
|
|
|
continue
|
2017-11-03 10:35:50 +00:00
|
|
|
k, v = d.split("=", 1)
|
2017-08-27 15:32:05 +00:00
|
|
|
params[k.strip().lower()] = v.strip()
|
|
|
|
if "auth" in params:
|
2017-10-04 09:32:56 +00:00
|
|
|
ac2dmToken = params["auth"]
|
2017-08-27 15:32:05 +00:00
|
|
|
elif "error" in params:
|
2017-10-13 09:20:40 +00:00
|
|
|
if "NeedsBrowser" in params["error"]:
|
2017-10-13 15:00:10 +00:00
|
|
|
raise LoginError("Security check is needed, try to visit "
|
|
|
|
"https://accounts.google.com/b/0/DisplayUnlockCaptcha "
|
|
|
|
"to unlock, or setup an app-specific password")
|
2017-08-27 15:32:05 +00:00
|
|
|
raise LoginError("server says: " + params["error"])
|
|
|
|
else:
|
2017-11-03 10:35:50 +00:00
|
|
|
if self.debug:
|
|
|
|
print('Last response text: %s' % self.lastResponseText)
|
2017-08-27 15:32:05 +00:00
|
|
|
raise LoginError("Auth token not found.")
|
|
|
|
|
2017-10-13 17:12:06 +00:00
|
|
|
self.gsfId = self.checkin(email, ac2dmToken)
|
2017-09-16 17:12:06 +00:00
|
|
|
if self.debug:
|
|
|
|
print("Google Services Framework Id: %s" % "{0:x}".format(self.gsfId))
|
2017-09-15 20:11:44 +00:00
|
|
|
self.getAuthSubToken(email, encryptedPass)
|
2017-09-16 17:12:06 +00:00
|
|
|
if self.debug:
|
|
|
|
print("Uploading device configuration")
|
2017-09-15 20:11:44 +00:00
|
|
|
self.uploadDeviceConfig()
|
2017-10-04 09:32:56 +00:00
|
|
|
elif gsfId is not None and authSubToken is not None:
|
|
|
|
# no need to initialize API
|
|
|
|
self.gsfId = gsfId
|
|
|
|
self.setAuthSubToken(authSubToken)
|
|
|
|
# check if token is valid with a simple search
|
|
|
|
self.search('firefox', 1, None)
|
|
|
|
else:
|
|
|
|
raise LoginError('Either (email,pass) or (gsfId, authSubToken) is needed')
|
2017-09-15 20:11:44 +00:00
|
|
|
|
|
|
|
def getAuthSubToken(self, email, passwd):
|
2017-11-03 10:35:50 +00:00
|
|
|
requestParams = self.deviceBuilder.getAuthParams(email, passwd)
|
|
|
|
response = requests.post(self.AUTHURL, data=requestParams, verify=ssl_verify)
|
2017-09-15 20:11:44 +00:00
|
|
|
data = response.text.split()
|
2017-11-03 10:35:50 +00:00
|
|
|
if self.debug:
|
|
|
|
self.lastResponseText = response.text
|
2017-09-15 20:11:44 +00:00
|
|
|
params = {}
|
|
|
|
for d in data:
|
|
|
|
if "=" not in d:
|
|
|
|
continue
|
2017-11-03 10:35:50 +00:00
|
|
|
k, v = d.split("=", 1)
|
2017-09-15 20:11:44 +00:00
|
|
|
params[k.strip().lower()] = v.strip()
|
2017-11-03 10:35:50 +00:00
|
|
|
if "token" in params:
|
|
|
|
firstToken = params["token"]
|
|
|
|
if self.debug:
|
|
|
|
print('Master token: %s' % firstToken)
|
|
|
|
secondToken = self.getSecondRoundToken(requestParams, firstToken)
|
|
|
|
self.setAuthSubToken(secondToken)
|
2017-09-15 20:11:44 +00:00
|
|
|
elif "error" in params:
|
|
|
|
raise LoginError("server says: " + params["error"])
|
|
|
|
else:
|
2017-11-03 10:35:50 +00:00
|
|
|
if self.debug:
|
|
|
|
print('Last response text: %s' % self.lastResponseText)
|
2017-09-15 20:11:44 +00:00
|
|
|
raise LoginError("Auth token not found.")
|
|
|
|
|
2017-11-03 10:35:50 +00:00
|
|
|
def getSecondRoundToken(self, previousParams, firstToken):
|
|
|
|
previousParams['Token'] = firstToken
|
|
|
|
previousParams['service'] = 'androidmarket'
|
|
|
|
previousParams['check_email'] = '1'
|
|
|
|
previousParams['token_request_options'] = 'CAA4AQ=='
|
|
|
|
previousParams['system_partition'] = '1'
|
|
|
|
previousParams['_opt_is_called_from_account_manager'] = '1'
|
|
|
|
previousParams['google_play_services_version'] = '11518448'
|
|
|
|
previousParams.pop('Email')
|
|
|
|
previousParams.pop('EncryptedPasswd')
|
2017-11-07 13:02:23 +00:00
|
|
|
response = requests.post(self.AUTHURL,
|
|
|
|
data=previousParams,
|
|
|
|
verify=ssl_verify)
|
2017-11-03 10:35:50 +00:00
|
|
|
data = response.text.split()
|
|
|
|
if self.debug:
|
|
|
|
self.lastResponseText = response.text
|
|
|
|
params = {}
|
|
|
|
for d in data:
|
|
|
|
if "=" not in d:
|
|
|
|
continue
|
|
|
|
k, v = d.split("=", 1)
|
|
|
|
params[k.strip().lower()] = v.strip()
|
|
|
|
if "auth" in params:
|
|
|
|
return params["auth"]
|
|
|
|
elif "error" in params:
|
|
|
|
raise LoginError("server says: " + params["error"])
|
|
|
|
else:
|
|
|
|
if self.debug:
|
|
|
|
print('Last response text: %s' % self.lastResponseText)
|
|
|
|
raise LoginError("Auth token not found.")
|
2017-11-01 10:23:24 +00:00
|
|
|
|
2017-08-27 15:32:05 +00:00
|
|
|
def executeRequestApi2(self, path, datapost=None,
|
|
|
|
post_content_type="application/x-www-form-urlencoded; charset=UTF-8"):
|
2017-10-10 16:35:40 +00:00
|
|
|
if self.authSubToken is None:
|
2017-09-18 13:49:42 +00:00
|
|
|
raise Exception("You need to login before executing any request")
|
2017-10-08 17:36:36 +00:00
|
|
|
headers = self.getDefaultHeaders()
|
2017-08-27 15:32:05 +00:00
|
|
|
|
2017-10-08 17:36:36 +00:00
|
|
|
if datapost is not None:
|
|
|
|
headers["Content-Type"] = post_content_type
|
2017-08-27 15:32:05 +00:00
|
|
|
|
2017-10-08 17:36:36 +00:00
|
|
|
url = self.FDFE + path
|
|
|
|
if datapost is not None:
|
|
|
|
response = requests.post(url, data=str(datapost),
|
2017-10-19 11:14:27 +00:00
|
|
|
headers=headers, verify=ssl_verify,
|
|
|
|
timeout=60)
|
2017-10-08 17:36:36 +00:00
|
|
|
else:
|
|
|
|
response = requests.get(url, headers=headers,
|
2017-10-19 11:14:27 +00:00
|
|
|
verify=ssl_verify,
|
|
|
|
timeout=60)
|
2017-08-27 15:32:05 +00:00
|
|
|
|
2017-11-03 10:35:50 +00:00
|
|
|
if self.debug:
|
|
|
|
self.lastResponseText = response.text
|
2017-10-06 09:53:53 +00:00
|
|
|
message = googleplay_pb2.ResponseWrapper.FromString(response.content)
|
2017-09-18 13:49:42 +00:00
|
|
|
if message.commands.displayErrorMessage != "":
|
2017-11-03 10:35:50 +00:00
|
|
|
if self.debug:
|
|
|
|
print('Last response text: %s' % self.lastResponseText)
|
2017-09-18 14:33:11 +00:00
|
|
|
raise RequestError(message.commands.displayErrorMessage)
|
2017-08-27 15:32:05 +00:00
|
|
|
|
|
|
|
return message
|
|
|
|
|
2017-09-16 11:17:21 +00:00
|
|
|
def search(self, query, nb_result, offset=None):
|
2017-10-08 17:36:36 +00:00
|
|
|
""" Search the play store for an app.
|
|
|
|
|
|
|
|
nb_result is the maximum number of result to be returned.
|
|
|
|
|
|
|
|
offset is used to take result starting from an index.
|
|
|
|
"""
|
2017-10-10 16:35:40 +00:00
|
|
|
if self.authSubToken is None:
|
2017-09-18 13:49:42 +00:00
|
|
|
raise Exception("You need to login before executing any request")
|
2017-08-27 15:32:05 +00:00
|
|
|
|
2017-10-05 18:39:24 +00:00
|
|
|
remaining = nb_result
|
|
|
|
output = []
|
|
|
|
|
|
|
|
nextPath = "search?c=3&q=%s" % requests.utils.quote(query)
|
2017-08-27 15:32:05 +00:00
|
|
|
if (offset is not None):
|
2017-10-05 18:39:24 +00:00
|
|
|
nextPath += "&o=%d" % int(offset)
|
2017-10-10 16:35:40 +00:00
|
|
|
while remaining > 0 and nextPath is not None:
|
2017-10-05 18:39:24 +00:00
|
|
|
currentPath = nextPath
|
|
|
|
data = self.executeRequestApi2(currentPath)
|
2017-10-08 17:36:36 +00:00
|
|
|
if len(data.preFetch) > 0:
|
|
|
|
response = data.preFetch[0].response
|
2017-10-05 18:39:24 +00:00
|
|
|
else:
|
2017-10-08 17:36:36 +00:00
|
|
|
response = data
|
|
|
|
if response.payload.HasField('searchResponse'):
|
|
|
|
# we still need to fetch the first page, so go to
|
|
|
|
# next loop iteration without decrementing counter
|
|
|
|
nextPath = response.payload.searchResponse.nextPageUrl
|
|
|
|
continue
|
|
|
|
|
2017-10-19 11:14:27 +00:00
|
|
|
if len(response.payload.listResponse.cluster) == 0:
|
|
|
|
# strange behaviour, probably due to
|
|
|
|
# expired token
|
2017-11-03 10:35:50 +00:00
|
|
|
if self.debug:
|
|
|
|
print('Last response text: %s' % self.lastResponseText)
|
2017-10-22 15:25:58 +00:00
|
|
|
raise LoginError('Unexpected behaviour, probably expired '
|
2017-10-30 16:30:10 +00:00
|
|
|
'token')
|
2017-10-08 17:36:36 +00:00
|
|
|
cluster = response.payload.listResponse.cluster[0]
|
2017-11-05 14:43:23 +00:00
|
|
|
if len(cluster.doc) == 0:
|
|
|
|
print('No results for query %s' % query)
|
2017-11-05 11:58:38 +00:00
|
|
|
break
|
2017-10-05 18:39:24 +00:00
|
|
|
if cluster.doc[0].containerMetadata.nextPageUrl != "":
|
|
|
|
nextPath = cluster.doc[0].containerMetadata.nextPageUrl
|
|
|
|
else:
|
|
|
|
nextPath = None
|
2017-10-17 09:41:39 +00:00
|
|
|
apps = list(chain.from_iterable([doc.child for doc in cluster.doc]))
|
2017-10-05 18:39:24 +00:00
|
|
|
output += list(map(utils.fromDocToDictionary, apps))
|
|
|
|
remaining -= len(apps)
|
|
|
|
|
|
|
|
if len(output) > nb_result:
|
|
|
|
output = output[:nb_result]
|
|
|
|
|
2017-09-18 13:49:42 +00:00
|
|
|
return output
|
2017-08-27 15:32:05 +00:00
|
|
|
|
|
|
|
def details(self, packageName):
|
|
|
|
"""Get app details from a package name.
|
2017-10-08 17:50:29 +00:00
|
|
|
|
2017-08-27 15:32:05 +00:00
|
|
|
packageName is the app unique ID (usually starting with 'com.')."""
|
|
|
|
path = "details?doc=%s" % requests.utils.quote(packageName)
|
2017-10-08 17:50:29 +00:00
|
|
|
data = self.executeRequestApi2(path)
|
2017-11-11 20:59:14 +00:00
|
|
|
return utils.fromDocToDictionary(data.payload.detailsResponse.docV2)
|
2017-08-27 15:32:05 +00:00
|
|
|
|
|
|
|
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
|
2017-11-02 16:34:49 +00:00
|
|
|
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
|
2017-08-27 15:32:05 +00:00
|
|
|
|
2017-11-02 16:34:49 +00:00
|
|
|
Args:
|
|
|
|
packageNames (list): a list of app IDs (usually starting with 'com.').
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
a list of dictionaries containing docv1 data, or None
|
|
|
|
if the app doesn't exist"""
|
2017-09-18 14:33:11 +00:00
|
|
|
|
2017-08-27 15:32:05 +00:00
|
|
|
path = "bulkDetails"
|
|
|
|
req = googleplay_pb2.BulkDetailsRequest()
|
|
|
|
req.docid.extend(packageNames)
|
|
|
|
data = req.SerializeToString()
|
|
|
|
message = self.executeRequestApi2(path,
|
|
|
|
data.decode("utf-8"),
|
|
|
|
"application/x-protobuf")
|
2017-09-18 14:33:11 +00:00
|
|
|
response = message.payload.bulkDetailsResponse
|
2017-11-11 20:59:14 +00:00
|
|
|
return [None if not entry.HasField('doc') else
|
|
|
|
utils.fromDocToDictionary(entry.doc)
|
|
|
|
for entry in response.entry]
|
2017-08-27 15:32:05 +00:00
|
|
|
|
2017-10-05 19:51:43 +00:00
|
|
|
def browse(self, cat=None, subCat=None):
|
2017-10-06 09:53:53 +00:00
|
|
|
"""Browse categories. If neither cat nor subcat are specified,
|
|
|
|
return a list of categories, otherwise it return a list of apps
|
|
|
|
using cat (category ID) and subCat (subcategory ID) as filters."""
|
2017-08-27 15:32:05 +00:00
|
|
|
path = "browse?c=3"
|
|
|
|
if cat is not None:
|
|
|
|
path += "&cat=%s" % requests.utils.quote(cat)
|
2017-10-05 19:51:43 +00:00
|
|
|
if subCat is not None:
|
|
|
|
path += "&ctr=%s" % requests.utils.quote(subCat)
|
|
|
|
data = self.executeRequestApi2(path)
|
2017-10-06 09:53:53 +00:00
|
|
|
output = []
|
|
|
|
|
|
|
|
if cat is None and subCat is None:
|
|
|
|
# result contains all categories available
|
2017-11-11 20:59:14 +00:00
|
|
|
return [{'name': c.name,
|
|
|
|
'dataUrl': c.dataUrl,
|
|
|
|
'catId': c.unknownCategoryContainer.categoryIdContainer.categoryId}
|
|
|
|
for c in data.payload.browseResponse.category]
|
2017-10-06 09:53:53 +00:00
|
|
|
else:
|
|
|
|
# result contains apps of a specific category
|
|
|
|
# organized by sections
|
|
|
|
for pf in data.preFetch:
|
|
|
|
for cluster in pf.response.payload.listResponse.cluster:
|
|
|
|
for doc in cluster.doc:
|
2017-10-10 16:35:40 +00:00
|
|
|
apps = [a for a in doc.child]
|
|
|
|
apps = list(map(utils.fromDocToDictionary,
|
|
|
|
apps))
|
|
|
|
section = {'title': doc.title,
|
|
|
|
'docid': doc.docid,
|
|
|
|
'apps': apps}
|
2017-10-06 09:53:53 +00:00
|
|
|
output.append(section)
|
2017-10-05 19:51:43 +00:00
|
|
|
return output
|
2017-08-27 15:32:05 +00:00
|
|
|
|
|
|
|
def list(self, cat, ctr=None, nb_results=None, offset=None):
|
2017-10-06 09:53:53 +00:00
|
|
|
"""List apps for a specfic category *cat*.
|
2017-08-27 15:32:05 +00:00
|
|
|
|
|
|
|
If ctr (subcategory ID) is None, returns a list of valid subcategories.
|
|
|
|
|
|
|
|
If ctr is provided, list apps within this subcategory."""
|
|
|
|
path = "list?c=3&cat=%s" % requests.utils.quote(cat)
|
|
|
|
if ctr is not None:
|
|
|
|
path += "&ctr=%s" % requests.utils.quote(ctr)
|
|
|
|
if nb_results is not None:
|
|
|
|
path += "&n=%s" % requests.utils.quote(nb_results)
|
|
|
|
if offset is not None:
|
|
|
|
path += "&o=%s" % requests.utils.quote(offset)
|
2017-10-06 09:53:53 +00:00
|
|
|
data = self.executeRequestApi2(path)
|
|
|
|
if ctr is None:
|
|
|
|
# list subcategories
|
2017-11-11 20:59:14 +00:00
|
|
|
clusters = chain.from_iterable([pf.response.payload.listResponse.cluster
|
|
|
|
for pf in data.preFetch])
|
|
|
|
docs = chain.from_iterable([c.doc for c in clusters])
|
|
|
|
return [d.docid for d in docs]
|
2017-10-06 09:53:53 +00:00
|
|
|
else:
|
|
|
|
# list apps for specific subcat
|
2017-11-11 20:59:14 +00:00
|
|
|
docs = chain.from_iterable([c.doc for c in
|
|
|
|
data.payload.listResponse.cluster])
|
|
|
|
childs = chain.from_iterable([d.child for d in docs])
|
|
|
|
return [utils.fromDocToDictionary(c)
|
|
|
|
for c in childs]
|
2017-08-27 15:32:05 +00:00
|
|
|
|
|
|
|
def reviews(self, packageName, filterByDevice=False, sort=2,
|
|
|
|
nb_results=None, offset=None):
|
2017-10-17 09:41:39 +00:00
|
|
|
"""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
|
|
|
|
"""
|
2017-08-27 15:32:05 +00:00
|
|
|
path = "rev?doc=%s&sort=%d" % (requests.utils.quote(packageName), sort)
|
|
|
|
if (nb_results is not None):
|
|
|
|
path += "&n=%d" % int(nb_results)
|
|
|
|
if (offset is not None):
|
|
|
|
path += "&o=%d" % int(offset)
|
|
|
|
if(filterByDevice):
|
|
|
|
path += "&dfil=1"
|
2017-10-08 19:25:04 +00:00
|
|
|
data = self.executeRequestApi2(path)
|
2017-10-10 16:55:03 +00:00
|
|
|
output = []
|
|
|
|
for rev in data.payload.reviewResponse.getResponse.review:
|
|
|
|
author = {'personIdString': rev.author2.personIdString,
|
|
|
|
'personId': rev.author2.personId,
|
|
|
|
'name': rev.author2.name,
|
|
|
|
'profilePicUrl': rev.author2.urls.url,
|
|
|
|
'googlePlusUrl': rev.author2.googlePlusUrl}
|
|
|
|
review = {'documentVersion': rev.documentVersion,
|
|
|
|
'timestampMsec': rev.timestampMsec,
|
|
|
|
'starRating': rev.starRating,
|
|
|
|
'comment': rev.comment,
|
|
|
|
'commentId': rev.commentId,
|
|
|
|
'author': author}
|
|
|
|
output.append(review)
|
|
|
|
return output
|
2017-08-27 15:32:05 +00:00
|
|
|
|
2017-10-17 09:41:39 +00:00
|
|
|
def _deliver_data(self, url, cookies, progress_bar):
|
|
|
|
headers = self.getDefaultHeaders()
|
|
|
|
if not progress_bar:
|
2017-11-11 20:59:14 +00:00
|
|
|
return requests.get(url, headers=headers,
|
|
|
|
cookies=cookies, verify=ssl_verify,
|
|
|
|
timeout=60).content
|
2017-10-17 09:41:39 +00:00
|
|
|
response_content = bytes()
|
2017-10-19 11:14:27 +00:00
|
|
|
response = requests.get(url, headers=headers,
|
|
|
|
cookies=cookies, verify=ssl_verify,
|
|
|
|
stream=True, timeout=60)
|
2017-10-17 09:41:39 +00:00
|
|
|
total_length = int(response.headers.get('content-length'))
|
2017-10-30 16:30:10 +00:00
|
|
|
chunk_size = 32 * (1 << 10) # 32 KB
|
2017-10-17 09:41:39 +00:00
|
|
|
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
|
|
|
|
|
2017-11-09 11:25:39 +00:00
|
|
|
def delivery(self, packageName, versionCode=None, offerType=1,
|
2017-10-17 15:57:31 +00:00
|
|
|
downloadToken=None, progress_bar=False, expansion_files=False):
|
2017-10-07 16:21:05 +00:00
|
|
|
"""Download an already purchased app.
|
|
|
|
|
2017-10-17 09:41:39 +00:00
|
|
|
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:
|
2017-10-07 16:21:05 +00:00
|
|
|
|
2017-10-17 09:41:39 +00:00
|
|
|
[main|patch].<expansion-version>.<package-name>.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
|
|
|
|
"""
|
2017-11-09 11:25:39 +00:00
|
|
|
|
|
|
|
if versionCode is None:
|
|
|
|
# pick up latest version
|
|
|
|
versionCode = self.details(packageName)['versionCode']
|
|
|
|
|
2017-10-07 16:21:05 +00:00
|
|
|
path = "delivery"
|
2017-10-10 16:35:40 +00:00
|
|
|
params = {'ot': str(offerType),
|
|
|
|
'doc': packageName,
|
|
|
|
'vc': str(versionCode)}
|
2017-10-07 16:21:05 +00:00
|
|
|
headers = self.getDefaultHeaders()
|
|
|
|
if downloadToken is not None:
|
|
|
|
params['dtok'] = downloadToken
|
|
|
|
url = "https://android.clients.google.com/fdfe/%s" % path
|
|
|
|
response = requests.get(url, headers=headers,
|
2017-10-19 11:14:27 +00:00
|
|
|
params=params, verify=ssl_verify,
|
|
|
|
timeout=60)
|
2017-10-07 16:21:05 +00:00
|
|
|
resObj = googleplay_pb2.ResponseWrapper.FromString(response.content)
|
|
|
|
if resObj.commands.displayErrorMessage != "":
|
2017-11-03 10:35:50 +00:00
|
|
|
if self.debug:
|
|
|
|
print('Last response text: %s' % self.lastResponseText)
|
2017-10-07 16:21:05 +00:00
|
|
|
raise RequestError(resObj.commands.displayErrorMessage)
|
|
|
|
elif resObj.payload.deliveryResponse.appDeliveryData.downloadUrl == "":
|
|
|
|
raise RequestError('App not purchased')
|
|
|
|
else:
|
2017-10-17 09:41:39 +00:00
|
|
|
result = {}
|
|
|
|
result['docId'] = packageName
|
|
|
|
result['additionalData'] = []
|
2017-10-07 16:21:05 +00:00
|
|
|
downloadUrl = resObj.payload.deliveryResponse.appDeliveryData.downloadUrl
|
|
|
|
cookie = resObj.payload.deliveryResponse.appDeliveryData.downloadAuthCookie[0]
|
|
|
|
cookies = {
|
|
|
|
str(cookie.name): str(cookie.value)
|
|
|
|
}
|
2017-10-17 09:41:39 +00:00
|
|
|
result['data'] = self._deliver_data(downloadUrl, cookies, progress_bar)
|
2017-10-17 15:57:31 +00:00
|
|
|
if not expansion_files:
|
|
|
|
return result
|
2017-10-17 09:41:39 +00:00
|
|
|
for obb in resObj.payload.deliveryResponse.appDeliveryData.additionalFile:
|
|
|
|
a = {}
|
2017-10-17 15:57:31 +00:00
|
|
|
# fileType == 0 -> main
|
|
|
|
# fileType == 1 -> patch
|
2017-10-17 09:41:39 +00:00
|
|
|
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
|
2017-10-07 16:21:05 +00:00
|
|
|
|
2017-11-09 11:25:39 +00:00
|
|
|
def download(self, packageName, versionCode=None, offerType=1,
|
2017-10-17 15:57:31 +00:00
|
|
|
progress_bar=False, expansion_files=False):
|
2017-10-07 16:21:05 +00:00
|
|
|
"""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.
|
2017-08-27 15:32:05 +00:00
|
|
|
|
2017-10-17 09:41:39 +00:00
|
|
|
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
|
2017-08-27 15:32:05 +00:00
|
|
|
|
2017-10-17 09:41:39 +00:00
|
|
|
Returns
|
|
|
|
Dictionary containing apk data and optional expansion files
|
|
|
|
(see *delivery*)
|
|
|
|
"""
|
2017-09-16 12:57:21 +00:00
|
|
|
|
2017-10-10 16:35:40 +00:00
|
|
|
if self.authSubToken is None:
|
2017-09-18 13:49:42 +00:00
|
|
|
raise Exception("You need to login before executing any request")
|
2017-08-27 15:32:05 +00:00
|
|
|
|
2017-11-09 11:25:39 +00:00
|
|
|
if versionCode is None:
|
|
|
|
# pick up latest version
|
|
|
|
versionCode = self.details(packageName)['versionCode']
|
|
|
|
|
2017-09-18 13:49:42 +00:00
|
|
|
path = "purchase"
|
2017-09-16 12:57:21 +00:00
|
|
|
headers = self.getDefaultHeaders()
|
2017-11-11 20:59:14 +00:00
|
|
|
params = {'ot': str(offerType),
|
|
|
|
'doc': packageName,
|
|
|
|
'vc': str(versionCode)}
|
2017-09-17 10:29:42 +00:00
|
|
|
url = self.FDFE + path
|
2017-09-16 12:57:21 +00:00
|
|
|
response = requests.post(url, headers=headers,
|
2017-10-19 11:14:27 +00:00
|
|
|
params=params, verify=ssl_verify,
|
|
|
|
timeout=60)
|
2017-09-16 12:57:21 +00:00
|
|
|
|
|
|
|
resObj = googleplay_pb2.ResponseWrapper.FromString(response.content)
|
2017-09-16 17:12:06 +00:00
|
|
|
if resObj.commands.displayErrorMessage != "":
|
2017-11-03 10:35:50 +00:00
|
|
|
if self.debug:
|
|
|
|
print('Last response text: %s' % self.lastResponseText)
|
2017-09-18 14:33:11 +00:00
|
|
|
raise RequestError(resObj.commands.displayErrorMessage)
|
2017-09-16 12:57:21 +00:00
|
|
|
else:
|
|
|
|
dlToken = resObj.payload.buyResponse.downloadToken
|
2017-10-17 15:57:31 +00:00
|
|
|
return self.delivery(packageName, versionCode, offerType, dlToken,
|
|
|
|
progress_bar=progress_bar, expansion_files=expansion_files)
|
2017-09-16 12:57:21 +00:00
|
|
|
|
2017-10-09 19:34:40 +00:00
|
|
|
@staticmethod
|
|
|
|
def getDevicesCodenames():
|
|
|
|
return config.getDevicesCodenames()
|
2017-10-10 17:23:58 +00:00
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def getDevicesReadableNames():
|
|
|
|
return config.getDevicesReadableNames()
|