Updated Protobuf definition and parsing

Newer definition have been fetched from play-store-api. Moreover, the
following enhanchements are introduced:

- An new function utils.parseProtobufObj will automatically parse any
Protobuf object into a dictionary, so there's no need to manually
specify each field to parse
- Added toc() and acceptTos() functions
- Added deviceCheckinConsistencyToken and dfeCookie to API state and
headers
- Since search results are now divided into clusters, each one
representing a different subcategory, now the search() API call returns
a list of clusters, each one containing a list of apps
This commit is contained in:
Domenico Iezzi 2018-12-12 17:23:07 +01:00
parent 86e069b541
commit 46b427124d
No known key found for this signature in database
GPG Key ID: D8CE73FC9E4393C3
5 changed files with 3156 additions and 1809 deletions

View File

@ -1,8 +1,9 @@
syntax = "proto2"; syntax = "proto2";
// Both sha1 and sha256 are encoded with base64 with URL and Filename Safe Alphabet with padding removed
message AndroidAppDeliveryData { message AndroidAppDeliveryData {
optional int64 downloadSize = 1; optional int64 downloadSize = 1;
optional string signature = 2; optional string sha1 = 2;
optional string downloadUrl = 3; optional string downloadUrl = 3;
repeated AppFileMetadata additionalFile = 4; repeated AppFileMetadata additionalFile = 4;
repeated HttpCookie downloadAuthCookie = 5; repeated HttpCookie downloadAuthCookie = 5;
@ -13,10 +14,23 @@ message AndroidAppDeliveryData {
optional bool immediateStartNeeded = 10; optional bool immediateStartNeeded = 10;
optional AndroidAppPatchData patchData = 11; optional AndroidAppPatchData patchData = 11;
optional EncryptionParams encryptionParams = 12; optional EncryptionParams encryptionParams = 12;
optional string downloadUrlGzipped = 13;
optional int64 downloadSizeGzipped = 14;
repeated Split split = 15;
optional string sha256 = 19;
}
message Split {
optional string name = 1;
optional int64 size = 2;
optional int64 sizeGzipped = 3;
optional string sha1 = 4;
optional string downloadUrl = 5;
optional string downloadUrlGzipped = 6;
optional string sha256 = 9;
} }
message AndroidAppPatchData { message AndroidAppPatchData {
optional int32 baseVersionCode = 1; optional int32 baseVersionCode = 1;
optional string baseSignature = 2; optional string baseSha1 = 2;
optional string downloadUrl = 3; optional string downloadUrl = 3;
optional int32 patchFormat = 4; optional int32 patchFormat = 4;
optional int64 maxPatchSize = 5; optional int64 maxPatchSize = 5;
@ -26,6 +40,9 @@ message AppFileMetadata {
optional int32 versionCode = 2; optional int32 versionCode = 2;
optional int64 size = 3; optional int64 size = 3;
optional string downloadUrl = 4; optional string downloadUrl = 4;
optional int64 sizeGzipped = 6;
optional string downloadUrlGzipped = 7;
optional string sha1 = 8;
} }
message EncryptionParams { message EncryptionParams {
optional int32 version = 1; optional int32 version = 1;
@ -232,7 +249,10 @@ message Offer {
optional SubscriptionTerms subscriptionTerms = 12; optional SubscriptionTerms subscriptionTerms = 12;
optional string formattedName = 13; optional string formattedName = 13;
optional string formattedDescription = 14; optional string formattedDescription = 14;
optional string saleEnds = 31; optional bool sale = 22;
optional string message = 26;
optional int64 saleEndTimestamp = 30;
optional string saleMessage = 31;
} }
message OwnershipInfo { message OwnershipInfo {
optional int64 initiationTimestampMsec = 1; optional int64 initiationTimestampMsec = 1;
@ -353,11 +373,34 @@ message DetailsResponse {
optional DocV2 docV2 = 4; optional DocV2 docV2 = 4;
optional string footerHtml = 5; optional string footerHtml = 5;
repeated Badge badge = 7; repeated Badge badge = 7;
optional Features features = 12;
optional string detailsStreamUrl = 13;
optional string userReviewUrl = 14;
optional string postAcquireDetailsStreamUrl = 17;
} }
message Badge { message Badge {
optional string label = 1; optional string label = 1;
optional Image image = 2;
optional BadgeContainer1 badgeContainer1 = 4;
optional string message = 11; optional string message = 11;
} }
message BadgeContainer1 {
optional BadgeContainer2 badgeContainer2 = 1;
}
message BadgeContainer2 {
optional BadgeLinkContainer badgeLinkContainer = 2;
}
message BadgeLinkContainer {
optional string link = 2;
}
message Features {
repeated Feature featurePresence = 1;
repeated Feature featureRating = 2;
}
message Feature {
optional string label = 1;
optional string value = 3;
}
message DeviceConfigurationProto { message DeviceConfigurationProto {
optional int32 touchScreen = 1; optional int32 touchScreen = 1;
optional int32 keyboard = 2; optional int32 keyboard = 2;
@ -425,6 +468,7 @@ message Image {
optional string url = 12; optional string url = 12;
} }
optional string color = 15; optional string color = 15;
optional int32 screenshotSetNumber = 21;
} }
message TranslatedText { message TranslatedText {
optional string text = 1; optional string text = 1;
@ -467,10 +511,13 @@ message AppDetails {
repeated FileMetadata file = 17; repeated FileMetadata file = 17;
optional string appType = 18; optional string appType = 18;
optional bool unstable = 21; optional bool unstable = 21;
optional bool hasInstantLink = 24;
optional string containsAds = 30; optional string containsAds = 30;
optional Dependencies dependencies = 34; optional Dependencies dependencies = 34;
optional TestingProgramInfo testingProgramInfo = 35; optional TestingProgramInfo testingProgramInfo = 35;
optional EarlyAccessInfo earlyAccessInfo = 36; optional EarlyAccessInfo earlyAccessInfo = 36;
optional string instantLink = 43;
optional string developerAddress = 45;
} }
message Dependencies { message Dependencies {
optional int32 unknown1 = 1; optional int32 unknown1 = 1;
@ -610,7 +657,7 @@ message Bucket {
} }
message ListResponse { message ListResponse {
repeated Bucket bucket = 1; repeated Bucket bucket = 1;
repeated SearchCluster cluster = 2; repeated DocV2 doc = 2;
} }
message DocV1 { message DocV1 {
optional Document finskyDoc = 1; optional Document finskyDoc = 1;
@ -655,7 +702,11 @@ message DocV2 {
optional string purchaseDetailsUrl = 20; optional string purchaseDetailsUrl = 20;
optional bool detailsReusable = 21; optional bool detailsReusable = 21;
optional string subtitle = 22; optional string subtitle = 22;
optional UnknownCategoryContainer unknownCategoryContainer = 24;
optional Unknown25 unknown25 = 25; optional Unknown25 unknown25 = 25;
optional string descriptionShort = 27;
optional string reviewSnippetsUrl = 31;
optional string reviewQuestionsUrl = 34;
} }
message Unknown25 { message Unknown25 {
repeated Unknown25Item item = 2; repeated Unknown25Item item = 2;
@ -679,6 +730,7 @@ message RelatedLinksUnknown1 {
optional RelatedLinksUnknown2 unknown2 = 2; optional RelatedLinksUnknown2 unknown2 = 2;
} }
message RelatedLinksUnknown2 { message RelatedLinksUnknown2 {
optional string homeUrl = 2;
optional string nextPageUrl = 3; optional string nextPageUrl = 3;
} }
message Rated { message Rated {
@ -830,19 +882,74 @@ message AggregateRating {
optional uint64 commentCount = 11; optional uint64 commentCount = 11;
optional double bayesianMeanRating = 12; optional double bayesianMeanRating = 12;
} }
message AcceptTosResponse {
}message CarrierBillingConfig {
optional string id = 1;
optional string name = 2;
optional int32 apiVersion = 3;
optional string provisioningUrl = 4;
optional string credentialsUrl = 5;
optional bool tosRequired = 6;
optional bool perTransactionCredentialsRequired = 7;
optional bool sendSubscriberIdWithCarrierBillingRequests = 8;
}
message BillingConfig {
optional CarrierBillingConfig carrierBillingConfig = 1;
optional int32 maxIabApiVersion = 2;
}
message CorpusMetadata {
optional int32 backend = 1;
optional string name = 2;
optional string landingUrl = 3;
optional string libraryName = 4;
optional string recsWidgetUrl = 6;
optional string shopName = 7;
}
message Experiments {
repeated string experimentId = 1;
}
message SelfUpdateConfig {
optional int32 latestClientVersionCode = 1;
}
message TocResponse {
repeated CorpusMetadata corpus = 1;
optional int32 tosVersionDeprecated = 2;
optional string tosContent = 3;
optional string homeUrl = 4;
optional Experiments experiments = 5;
optional string tosCheckboxTextMarketingEmails = 6;
optional string tosToken = 7;
optional string iconOverrideUrl = 9;
optional SelfUpdateConfig selfUpdateConfig = 10;
optional bool requiresUploadDeviceConfig = 11;
optional BillingConfig billingConfig = 12;
optional string recsWidgetUrl = 13;
optional string socialHomeUrl = 15;
optional bool ageVerificationRequired = 16;
optional bool gplusSignupEnabled = 17;
optional bool redeemEnabled = 18;
optional string helpUrl = 19;
optional int32 themeId = 20;
optional string entertainmentHomeUrl = 21;
optional string cookie = 22;
}
message Payload { message Payload {
optional ListResponse listResponse = 1; optional ListResponse listResponse = 1;
optional DetailsResponse detailsResponse = 2; optional DetailsResponse detailsResponse = 2;
optional ReviewResponse reviewResponse = 3; optional ReviewResponse reviewResponse = 3;
optional BuyResponse buyResponse = 4; optional BuyResponse buyResponse = 4;
optional SearchResponse searchResponse = 5; optional SearchResponse searchResponse = 5;
optional TocResponse tocResponse = 6;
optional BrowseResponse browseResponse = 7; optional BrowseResponse browseResponse = 7;
optional PurchaseStatusResponse purchaseStatusResponse = 8; optional PurchaseStatusResponse purchaseStatusResponse = 8;
optional string logResponse = 10; optional string logResponse = 10;
optional string flagContentResponse = 13;
optional BulkDetailsResponse bulkDetailsResponse = 19; optional BulkDetailsResponse bulkDetailsResponse = 19;
optional DeliveryResponse deliveryResponse = 21; optional DeliveryResponse deliveryResponse = 21;
optional UploadDeviceConfigResponse uploadDeviceConfigResponse = 25; optional AcceptTosResponse acceptTosResponse = 22;
optional AndroidCheckinResponse androidCheckinResponse = 26; optional AndroidCheckinResponse androidCheckinResponse = 26;
optional UploadDeviceConfigResponse uploadDeviceConfigResponse = 28;
optional SearchSuggestResponse searchSuggestResponse = 40; optional SearchSuggestResponse searchSuggestResponse = 40;
optional TestingProgramResponse testingProgramResponse = 80; optional TestingProgramResponse testingProgramResponse = 80;
} }
@ -853,11 +960,40 @@ message PreFetch {
optional int64 ttl = 4; optional int64 ttl = 4;
optional int64 softTtl = 5; optional int64 softTtl = 5;
} }
message ServerMetadata {
optional int64 latencyMillis = 1;
}
message Targets {
repeated int64 targetId = 1;
optional bytes signature = 2;
}
message ServerCookie {
optional int32 type = 1;
optional bytes token = 2;
}
message ServerCookies {
repeated ServerCookie serverCookie = 1;
}
message ResponseWrapper { message ResponseWrapper {
optional Payload payload = 1; optional Payload payload = 1;
optional ServerCommands commands = 2; optional ServerCommands commands = 2;
repeated PreFetch preFetch = 3; repeated PreFetch preFetch = 3;
repeated Notification notification = 4; repeated Notification notification = 4;
optional ServerMetadata serverMetadata = 5;
optional Targets targets = 6;
optional ServerCookies serverCookies = 7;
optional bytes serverLogsCookie = 9;
}
message ResponseWrapperApi {
optional PayloadApi payload = 1;
}
message PayloadApi {
optional UserProfileResponse userProfileResponse = 5;
}
message UserProfileResponse {
optional UserProfile userProfile = 1;
} }
message ServerCommands { message ServerCommands {
optional bool clearCache = 1; optional bool clearCache = 1;
@ -881,30 +1017,23 @@ message Review {
optional string deviceName = 19; optional string deviceName = 19;
optional string replyText = 29; optional string replyText = 29;
optional int64 replyTimestampMsec = 30; optional int64 replyTimestampMsec = 30;
optional Author author = 31; optional ReviewAuthor author = 31;
optional Author2 author2 = 33; optional UserProfile userProfile = 33;
} }
message Author { message ReviewAuthor {
optional string name = 2; optional string name = 2;
optional Avatar urls = 5; optional Image avatar = 5;
} }
message Author2 { message UserProfile {
optional string personIdString = 1; optional string personIdString = 1;
optional string personId = 2; optional string personId = 2;
optional int32 unknown1 = 3; optional int32 unknown1 = 3;
optional int32 unknown2 = 4; optional int32 unknown2 = 4;
optional string name = 5; optional string name = 5;
optional Avatar urls = 10; repeated Image image = 10;
optional string googlePlusUrl = 19; optional string googlePlusUrl = 19;
optional string googlePlusTagline = 22; optional string googlePlusTagline = 22;
} }
message Avatar {
optional int32 unknown1 = 1;
optional string url = 5;
optional string secureUrl = 7;
optional bool unknown2 = 9;
optional bool unknown3 = 22;
}
message ReviewResponse { message ReviewResponse {
optional GetReviewsResponse getResponse = 1; optional GetReviewsResponse getResponse = 1;
optional string nextPageUrl = 2; optional string nextPageUrl = 2;
@ -927,18 +1056,6 @@ message SearchResponse {
repeated RelatedSearch relatedSearch = 6; repeated RelatedSearch relatedSearch = 6;
optional string nextPageUrl = 10; optional string nextPageUrl = 10;
} }
message UserProfileResponseWrapper {
optional UserProfilePayload payload = 1;
optional ServerCommands commands = 2;
repeated PreFetch preFetch = 3;
repeated Notification notification = 4;
}
message UserProfilePayload {
optional UserProfileResponse response = 5;
}
message UserProfileResponse {
repeated DocV2 doc = 1;
}
message SearchSuggestResponse { message SearchSuggestResponse {
repeated SearchSuggestEntry entry = 1; repeated SearchSuggestEntry entry = 1;
} }
@ -1018,6 +1135,7 @@ message AndroidCheckinResponse {
optional fixed64 securityToken = 8; optional fixed64 securityToken = 8;
optional bool settingsDiff = 9; optional bool settingsDiff = 9;
repeated string deleteSetting = 10; repeated string deleteSetting = 10;
optional string deviceCheckinConsistencyToken = 12;
} }
message GservicesSetting { message GservicesSetting {
optional bytes name = 1; optional bytes name = 1;
@ -1139,11 +1257,21 @@ message StatCounters {
message UsageStatsExtensionProto { message UsageStatsExtensionProto {
optional AndroidDataUsageProto dataUsage = 1; optional AndroidDataUsageProto dataUsage = 1;
} }
message SearchCluster { message ModifyLibraryRequest {
optional string id = 1; optional string libraryId = 1;
optional string type = 2; repeated string addPackageName = 2;
optional int64 int1 = 3; repeated string removePackageName = 3;
optional int64 int2 = 4; }
optional string category = 5; message UrlRequestWrapper {
repeated DocV2 doc = 11; optional DeveloperAppsRequest developerAppsRequest = 49;
}
message DeveloperAppsRequest {
optional DeveloperIdContainer developerIdContainer1 = 1;
optional DeveloperIdContainer developerIdContainer2 = 2;
optional int32 unknownInt3 = 3;
}
message DeveloperIdContainer {
optional string developerId = 1;
optional int32 unknownInt2 = 2;
optional int32 unknownInt3 = 3;
} }

View File

@ -31,6 +31,7 @@ SEARCH_SUGGEST_URL = FDFE + "searchSuggest"
BULK_URL = FDFE + "bulkDetails" BULK_URL = FDFE + "bulkDetails"
LOG_URL = FDFE + "log" LOG_URL = FDFE + "log"
TOC_URL = FDFE + "toc" TOC_URL = FDFE + "toc"
ACCEPT_TOS_URL = FDFE + "acceptTos"
LIST_URL = FDFE + "list" LIST_URL = FDFE + "list"
REVIEWS_URL = FDFE + "rev" REVIEWS_URL = FDFE + "rev"
@ -72,6 +73,8 @@ class GooglePlayAPI(object):
self.authSubToken = None self.authSubToken = None
self.gsfId = None self.gsfId = None
self.device_config_token = None self.device_config_token = None
self.deviceCheckinConsistencyToken = None
self.dfeCookie = None
self.proxies_config = proxies_config self.proxies_config = proxies_config
self.deviceBuilder = config.DeviceBuilder(device_codename) self.deviceBuilder = config.DeviceBuilder(device_codename)
self.set_locale(locale) self.set_locale(locale)
@ -141,6 +144,10 @@ class GooglePlayAPI(object):
headers["Authorization"] = "GoogleLogin auth=%s" % self.authSubToken headers["Authorization"] = "GoogleLogin auth=%s" % self.authSubToken
if self.device_config_token is not None: if self.device_config_token is not None:
headers["X-DFE-Device-Config-Token"] = self.device_config_token headers["X-DFE-Device-Config-Token"] = self.device_config_token
if self.deviceCheckinConsistencyToken is not None:
headers["X-DFE-Device-Checkin-Consistency-Token"] = self.deviceCheckinConsistencyToken
if self.dfeCookie is not None:
headers["X-DFE-Cookie"] = self.dfeCookie
return headers return headers
def checkin(self, email, ac2dmToken): def checkin(self, email, ac2dmToken):
@ -155,6 +162,7 @@ class GooglePlayAPI(object):
proxies=self.proxies_config) proxies=self.proxies_config)
response = googleplay_pb2.AndroidCheckinResponse() response = googleplay_pb2.AndroidCheckinResponse()
response.ParseFromString(res.content) response.ParseFromString(res.content)
self.deviceCheckinConsistencyToken = response.deviceCheckinConsistencyToken
# checkin again to upload gfsid # checkin again to upload gfsid
request.id = response.androidId request.id = response.androidId
@ -226,7 +234,7 @@ class GooglePlayAPI(object):
if "NeedsBrowser" in params["error"]: if "NeedsBrowser" in params["error"]:
raise SecurityCheckError("Security check is needed, try to visit " raise SecurityCheckError("Security check is needed, try to visit "
"https://accounts.google.com/b/0/DisplayUnlockCaptcha " "https://accounts.google.com/b/0/DisplayUnlockCaptcha "
"to unlock, or setup an app-specific passwor") "to unlock, or setup an app-specific password")
raise LoginError("server says: " + params["error"]) raise LoginError("server says: " + params["error"])
else: else:
raise LoginError("Auth token not found.") raise LoginError("Auth token not found.")
@ -239,7 +247,7 @@ class GooglePlayAPI(object):
self.gsfId = gsfId self.gsfId = gsfId
self.setAuthSubToken(authSubToken) self.setAuthSubToken(authSubToken)
# check if token is valid with a simple search # check if token is valid with a simple search
self.search('firefox', 1, None) self.search('drv', 1, None)
else: else:
raise LoginError('Either (email,pass) or (gsfId, authSubToken) is needed') raise LoginError('Either (email,pass) or (gsfId, authSubToken) is needed')
@ -335,10 +343,10 @@ class GooglePlayAPI(object):
"ssis": "120", "ssis": "120",
"sst": "2"} "sst": "2"}
data = self.executeRequestApi2(SEARCH_SUGGEST_URL, params=params) data = self.executeRequestApi2(SEARCH_SUGGEST_URL, params=params)
response = data.payload.searchSuggestResponse output = []
return [{"type": e.type, for entry in data.payload.searchSuggestResponse.entry:
"suggestedQuery": e.suggestedQuery, output.append(utils.parseProtobufObj(entry))
"title": e.title} for e in response.entry] return output
def search(self, query, nb_result, offset=None): def search(self, query, nb_result, offset=None):
""" Search the play store for an app. """ Search the play store for an app.
@ -347,49 +355,26 @@ class GooglePlayAPI(object):
offset is used to take result starting from an index. offset is used to take result starting from an index.
""" """
# TODO: correctly implement nb_results (for now it does nothing)
if self.authSubToken is None: if self.authSubToken is None:
raise Exception("You need to login before executing any request") raise Exception("You need to login before executing any request")
remaining = nb_result remaining = nb_result
output = [] output = []
nextPath = SEARCH_URL + "?c=3&q={}".format(requests.utils.quote(query)) path = SEARCH_URL + "?c=3&q={}".format(requests.utils.quote(query))
if (offset is not None): if (offset is not None):
nextPath += "&o={}".format(offset) nextPath += "&o={}".format(offset)
while remaining > 0 and nextPath is not None: # TODO: not sure if this toc call should be here
currentPath = nextPath self.toc()
data = self.executeRequestApi2(currentPath) data = self.executeRequestApi2(path)
if utils.hasPrefetch(data): if utils.hasPrefetch(data):
response = data.preFetch[0].response response = data.preFetch[0].response
else: else:
response = data response = data
if utils.hasSearchResponse(response.payload): output = []
# we still need to fetch the first page, so go to for cluster in response.payload.listResponse.doc[0].child:
# next loop iteration without decrementing counter output.append(utils.parseProtobufObj(cluster))
nextPath = FDFE + response.payload.searchResponse.nextPageUrl
continue
if utils.hasListResponse(response.payload):
cluster = response.payload.listResponse.cluster
if len(cluster) == 0:
# unexpected behaviour, probably due to expired token
raise LoginError('Unexpected behaviour, probably expired '
'token')
cluster = cluster[0]
if len(cluster.doc) == 0:
break
if cluster.doc[0].containerMetadata.nextPageUrl != "":
nextPath = FDFE + cluster.doc[0].containerMetadata.nextPageUrl
else:
nextPath = None
apps = []
for doc in cluster.doc:
apps.extend(doc.child)
output += list(map(utils.fromDocToDictionary, apps))
remaining -= len(apps)
if len(output) > nb_result:
output = output[:nb_result]
return output return output
def details(self, packageName): def details(self, packageName):
@ -398,7 +383,7 @@ class GooglePlayAPI(object):
packageName is the app unique ID (usually starting with 'com.').""" packageName is the app unique ID (usually starting with 'com.')."""
path = DETAILS_URL + "?doc={}".format(requests.utils.quote(packageName)) path = DETAILS_URL + "?doc={}".format(requests.utils.quote(packageName))
data = self.executeRequestApi2(path) data = self.executeRequestApi2(path)
return utils.fromDocToDictionary(data.payload.detailsResponse.docV2) return utils.parseProtobufObj(data.payload.detailsResponse.docV2)
def bulkDetails(self, packageNames): def bulkDetails(self, packageNames):
"""Get several apps details from a list of package names. """Get several apps details from a list of package names.
@ -424,18 +409,19 @@ class GooglePlayAPI(object):
params=params) params=params)
response = message.payload.bulkDetailsResponse response = message.payload.bulkDetailsResponse
return [None if not utils.hasDoc(entry) else return [None if not utils.hasDoc(entry) else
utils.fromDocToDictionary(entry.doc) utils.parseProtobufObj(entry.doc)
for entry in response.entry] for entry in response.entry]
def getHomeApps(self): def getHomeApps(self):
path = HOME_URL + "?c=3&nocache_isui=true" path = HOME_URL + "?c=3&nocache_isui=true"
data = self.executeRequestApi2(path) data = self.executeRequestApi2(path)
if utils.hasPrefetch(data):
response = data.preFetch[0].response
else:
response = data
output = [] output = []
cluster = data.preFetch[0].response.payload.listResponse.cluster[0] for cluster in response.payload.listResponse.doc[0].child:
for doc in cluster.doc: output.append(utils.parseProtobufObj(cluster))
output.append({"categoryId": doc.docid,
"categoryStr": doc.title,
"apps": [utils.fromDocToDictionary(c) for c in doc.child]})
return output return output
def browse(self, cat=None, subCat=None): def browse(self, cat=None, subCat=None):
@ -451,27 +437,24 @@ class GooglePlayAPI(object):
if cat is None and subCat is None: if cat is None and subCat is None:
# result contains all categories available # result contains all categories available
return [{'name': c.name, return [utils.parseProtobufObj(c) for c in data.payload.browseResponse.category]
'dataUrl': c.dataUrl,
'catId': c.unknownCategoryContainer.categoryIdContainer.categoryId}
for c in data.payload.browseResponse.category]
output = [] output = []
clusters = [] clusters = []
if utils.hasPrefetch(data): if utils.hasPrefetch(data):
for pf in data.preFetch: for pf in data.preFetch:
clusters.extend(pf.response.payload.listResponse.cluster) for cluster in pf.response.payload.listResponse.doc:
clusters.extend(cluster.child)
# result contains apps of a specific category # result contains apps of a specific category
# organized by sections # organized by sections
for cluster in clusters: for cluster in clusters:
for doc in cluster.doc: apps = [a for a in cluster.child]
apps = [a for a in doc.child] apps = list(map(utils.parseProtobufObj,
apps = list(map(utils.fromDocToDictionary,
apps)) apps))
section = {'title': doc.title, section = {'title': cluster.title,
'docid': doc.docid, 'docid': cluster.docid,
'apps': apps} 'apps': apps}
output.append(section) output.append(section)
return output return output
@ -495,19 +478,16 @@ class GooglePlayAPI(object):
if ctr is None: if ctr is None:
# list subcategories # list subcategories
for pf in data.preFetch: for pf in data.preFetch:
clusters.extend(pf.response.payload.listResponse.cluster) for cluster in pf.response.payload.listResponse.doc:
for c in clusters: clusters.extend(cluster.child)
docs.extend(c.doc) return [c.docid for c in clusters]
return [d.docid for d in docs]
else: else:
childs = [] apps = []
clusters.extend(data.payload.listResponse.cluster) for d in data.payload.listResponse.doc:
for c in clusters: for c in d.child: # category
docs.extend(c.doc) for a in c.child: # app
for d in docs: apps.append(utils.parseProtobufObj(a))
childs.extend(d.child) return apps
return [utils.fromDocToDictionary(c)
for c in childs]
def reviews(self, packageName, filterByDevice=False, sort=2, def reviews(self, packageName, filterByDevice=False, sort=2,
nb_results=None, offset=None): nb_results=None, offset=None):
@ -524,6 +504,7 @@ class GooglePlayAPI(object):
dict object containing all the protobuf data returned from dict object containing all the protobuf data returned from
the api the api
""" """
# TODO: select the number of reviews to return
path = REVIEWS_URL + "?doc={}&sort={}".format(requests.utils.quote(packageName), sort) path = REVIEWS_URL + "?doc={}&sort={}".format(requests.utils.quote(packageName), sort)
if nb_results is not None: if nb_results is not None:
path += "&n={}".format(nb_results) path += "&n={}".format(nb_results)
@ -533,19 +514,8 @@ class GooglePlayAPI(object):
path += "&dfil=1" path += "&dfil=1"
data = self.executeRequestApi2(path) data = self.executeRequestApi2(path)
output = [] output = []
for rev in data.payload.reviewResponse.getResponse.review: for review in data.payload.reviewResponse.getResponse.review:
author = {'personIdString': rev.author2.personIdString, output.append(utils.parseProtobufObj(review))
'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 return output
def _deliver_data(self, url, cookies): def _deliver_data(self, url, cookies):
@ -650,7 +620,8 @@ class GooglePlayAPI(object):
if versionCode is None: if versionCode is None:
# pick up latest version # pick up latest version
versionCode = self.details(packageName).get('versionCode') appDetails = self.details(packageName).get('details').get('appDetails')
versionCode = appDetails.get('versionCode')
headers = self.getHeaders() headers = self.getHeaders()
params = {'ot': str(offerType), params = {'ot': str(offerType),
@ -687,6 +658,35 @@ class GooglePlayAPI(object):
if response.commands.displayErrorMessage != "": if response.commands.displayErrorMessage != "":
raise RequestError(response.commands.displayErrorMessage) raise RequestError(response.commands.displayErrorMessage)
def toc(self):
response = requests.get(TOC_URL,
headers=self.getHeaders(),
verify=ssl_verify,
timeout=60,
proxies=self.proxies_config)
data = googleplay_pb2.ResponseWrapper.FromString(response.content)
tocResponse = data.payload.tocResponse
if utils.hasTosContent(tocResponse) and utils.hasTosToken(tocResponse):
self.acceptTos(tocResponse.tosToken)
if utils.hasCookie(tocResponse):
self.dfeCookie = tocResponse.cookie
return utils.parseProtobufObj(tocResponse)
def acceptTos(self, tosToken):
params = {
"tost": tosToken,
"toscme": "false"
}
response = requests.get(ACCEPT_TOS_URL,
headers=self.getHeaders(),
params=params,
verify=ssl_verify,
timeout=60,
proxies=self.proxies_config)
data = googleplay_pb2.ResponseWrapper.FromString(response.content)
return utils.parseProtobufObj(data.payload.acceptTosResponse)
@staticmethod @staticmethod
def getDevicesCodenames(): def getDevicesCodenames():
return config.getDevicesCodenames() return config.getDevicesCodenames()

File diff suppressed because one or more lines are too long

View File

@ -1,60 +1,36 @@
import struct import struct
import sys import sys
from google.protobuf.message import Message
from . import googleplay_pb2 from . import googleplay_pb2
VERSION = sys.version_info[0] VERSION = sys.version_info[0]
def fromDocToDictionary(app): def isIterable(obj):
return {"docId": app.docid, try:
"title": app.title, iter(obj)
"author": app.creator, return True
"description": app.descriptionHtml, except TypeError:
"recentChanges": app.details.appDetails.recentChangesHtml, return False
"offer": [{"micros": o.micros,
"currencyCode": o.currencyCode,
"formattedAmount": o.formattedAmount,
"checkoutFlowRequired": o.checkoutFlowRequired,
"offerType": o.offerType,
"saleEnds": o.saleEnds}
for o in app.offer],
"images": [{"imageType": img.imageType,
"width": img.dimension.width
if hasattr(img.dimension, "width")
else 0,
"height": img.dimension.height
if hasattr(img.dimension, "height")
else 0,
"url": img.imageUrl,
"supportsFifeUrlOptions": img.supportsFifeUrlOptions}
for img in app.image],
"versionCode": app.details.appDetails.versionCode,
"versionString": app.details.appDetails.versionString,
"installationSize": app.details.appDetails.installationSize,
"numDownloads": app.details.appDetails.numDownloads,
"uploadDate": app.details.appDetails.uploadDate,
"permission": [p for p in app.details.appDetails.permission],
"files": [{"fileType": f.fileType,
"version": f.versionCode,
"size": f.size}
for f in app.details.appDetails.file],
"unstable": app.details.appDetails.unstable,
"containsAds": app.details.appDetails.containsAds,
"aggregateRating": {"type": app.aggregateRating.type,
"starRating": app.aggregateRating.starRating,
"ratingsCount": app.aggregateRating.ratingsCount,
"oneStarRatings": app.aggregateRating.oneStarRatings,
"twoStarRatings": app.aggregateRating.twoStarRatings,
"threeStarRatings": app.aggregateRating.threeStarRatings,
"fourStarRatings": app.aggregateRating.fourStarRatings,
"fiveStarRatings": app.aggregateRating.fiveStarRatings,
"commentCount": app.aggregateRating.commentCount},
"dependencies": [{"packageName": d.packageName,
"version": d.version}
for d in app.details.appDetails.dependencies.dependency],
"category": {"appType": app.relatedLinks.categoryInfo.appType,
"appCategory": app.relatedLinks.categoryInfo.appCategory},
"detailsUrl": app.detailsUrl}
def isProtobuf(obj):
"""Really bad workaround to check if an object is an
instance of a protobuf message"""
return hasattr(obj, "MergeFrom")
def parseProtobufObj(obj):
output = {}
for (fd, value) in obj.ListFields():
key = fd.name
if isProtobuf(value):
if not isIterable(value):
output.update({key: parseProtobufObj(value)})
else:
output.update({
key: [parseProtobufObj(i) for i in value]
})
else:
output.update({key: value})
return output
def readInt(byteArray, start): def readInt(byteArray, start):
"""Read the byte array, starting from *start* position, """Read the byte array, starting from *start* position,
@ -92,6 +68,30 @@ def hasSearchResponse(obj):
except ValueError: except ValueError:
return False return False
def hasCluster(obj):
try:
return obj.HasField('cluster')
except ValueError:
return False
def hasTosContent(tocResponse):
try:
return tocResponse.HasField('tosContent')
except ValueError:
return False
def hasTosToken(tocResponse):
try:
return tocResponse.HasField('tosToken')
except ValueError:
return False
def hasCookie(tocResponse):
try:
return tocResponse.HasField('cookie')
except ValueError:
return False
def hasDoc(obj): def hasDoc(obj):
# doc an be a single object or a # doc an be a single object or a
# RepeatedComposite object # RepeatedComposite object

View File

@ -24,79 +24,60 @@ server.login(None, None, gsfId, authSubToken)
# SEARCH # SEARCH
apps = server.search('telegram', 34, None)
print('\nSearch suggestion for "fir"\n') print('\nSearch suggestion for "fir"\n')
print(server.searchSuggest('fir')) print(server.searchSuggest('fir'))
print('nb_result: 34') result = server.search('firefox', 34, None)
print('number of results: %d' % len(apps)) for cluster in result:
print("cluster: {}".format(cluster.get('docid')))
for app in cluster.get('child'):
print(" app: {}".format(app.get('docid')))
print('\nFound those apps:\n')
for a in apps:
print(a['docId'])
# HOME APPS # HOME APPS
print('\nFetching apps from play store home\n') print('\nFetching apps from play store home\n')
home = server.getHomeApps() result = server.getHomeApps()
for cluster in result:
print("cluster: {}".format(cluster.get('docid')))
for app in cluster.get('child'):
print(" app: {}".format(app.get('docid')))
for cat in home:
print("cat {0} with {1} apps".format(cat.get('categoryId'),
str(len(cat.get('apps')))))
# DOWNLOAD # DOWNLOAD
docid = apps[0]['docId'] docid = 'org.mozilla.focus'
print('\nTelegram docid is: %s\n' % docid) server.log(docid)
print('\nAttempting to download %s\n' % docid) print('\nAttempting to download {}\n'.format(docid))
fl = server.download(docid) fl = server.download(docid)
with open(docid + '.apk', 'wb') as apk_file: with open(docid + '.apk', 'wb') as apk_file:
for chunk in fl.get('file').get('data'): for chunk in fl.get('file').get('data'):
apk_file.write(chunk) apk_file.write(chunk)
print('\nDownload successful\n') print('\nDownload successful\n')
# DOWNLOAD APP NOT PURCHASED
# Attempting to download Nova Launcher Prime
# it should throw an error 'App Not Purchased'
print('\nAttempting to download "com.teslacoilsw.launcher.prime"\n')
errorThrown = False
try:
app = server.search('nova launcher prime', 3, None)
app = filter(lambda x: x['docId'] == 'com.teslacoilsw.launcher.prime', app)
app = list(app)[0]
fl = server.delivery(app['docId'], app['versionCode'])
except RequestError as e:
errorThrown = True
print(e)
if not errorThrown:
print('Download of previous app should have failed')
sys.exit(1)
# BULK DETAILS # BULK DETAILS
testApps = ['org.mozilla.focus', 'com.non.existing.app'] testApps = ['org.mozilla.focus', 'com.non.existing.app']
bulk = server.bulkDetails(testApps) bulk = server.bulkDetails(testApps)
print('\nTesting behaviour for non-existing apps\n') print('\nTesting behaviour for non-existing apps\n')
if bulk[1] is not None: if bulk[1] is not None:
print('bulkDetails should return None for non-existing apps') print('bulkDetails should return empty dict for non-existing apps')
sys.exit(1) sys.exit(1)
print('\nResult from bulkDetails for %s\n' % testApps[0]) print('\nResult from bulkDetails for {}\n'.format(testApps[0]))
print(bulk[0]) print(bulk[0]['docid'])
# DETAILS # DETAILS
print('\nGetting details for %s\n' % testApps[0]) print('\nGetting details for %s\n' % testApps[0])
details = server.details(testApps[0]) details = server.details(testApps[0])
print(details) print(details['title'])
# REVIEWS # REVIEWS
print('\nGetting reviews for %s\n' % testApps[0]) print('\nGetting reviews for %s\n' % testApps[0])
revs = server.reviews(testApps[0]) revs = server.reviews(testApps[0])
for r in revs: for r in revs:
print(r['comment']) print("UserId: {0} Vote: {1}".format(
r['userProfile']['personIdString'],
str(r['starRating'])))
# BROWSE # BROWSE
@ -105,8 +86,10 @@ browse = server.browse()
for b in browse: for b in browse:
print(b['name']) print(b['name'])
print('\nBrowsing the %s category\n' % browse[0]['catId']) sampleCat = browse[0]['unknownCategoryContainer']['categoryIdContainer']['categoryId']
browseCat = server.browse(browse[0]['catId']) print('\nBrowsing the {} category\n'.format(sampleCat))
browseCat = server.browse(sampleCat)
for b in browseCat: for b in browseCat:
print('%s subcategory with %d apps' % (b['title'], len(b['apps']))) print('%s subcategory with %d apps' % (b['title'], len(b['apps'])))
@ -121,4 +104,4 @@ for c in catList:
print('\nList %s apps for %s category\n' % (catList[0], cat)) print('\nList %s apps for %s category\n' % (catList[0], cat))
appList = server.list(cat, catList[0]) appList = server.list(cat, catList[0])
for app in appList: for app in appList:
print(app['docId']) print(app['docid'])