├── .github └── FUNDING.yml ├── .eslintrc ├── lib ├── constants.js ├── app.js ├── scores │ ├── index.js │ ├── traffic.js │ └── difficulty.js ├── calc.js ├── suggest │ ├── index.js │ └── strategies.js ├── retext.js ├── stores │ ├── itunes.js │ └── gplay.js └── visibility.js ├── .gitignore ├── package.json ├── LICENSE ├── index.js └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [facundoolano] 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "mocha": true, 5 | "node": true 6 | }, 7 | "extends": "semistandard", 8 | "plugins": [ 9 | "standard" 10 | ], 11 | "rules": { 12 | "no-unused-vars": [2, { "vars": "all", "args": "after-used" }] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /lib/constants.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // startegies to get the list of apps to compare 4 | module.exports = { 5 | SIMILAR: 'similar', // listed as similar in google play 6 | COMPETITION: 'competition', // top apps of the targetted kws 7 | CATEGORY: 'category', // top apps of the category 8 | ARBITRARY: 'arbitrary', // based on an arbitrary list of app ids 9 | KEYWORDS: 'keywords', // based on a list of seed keywords 10 | SEARCH: 'search' // based on search suggestion keywords 11 | }; 12 | -------------------------------------------------------------------------------- /lib/app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const R = require('ramda'); 4 | const getKeywords = require('./retext'); 5 | 6 | function build (store) { 7 | return function (appId) { 8 | let p; 9 | if (R.is(Object, appId)) { 10 | p = Promise.resolve(appId); 11 | } else { 12 | p = store.app({appId}); 13 | } 14 | 15 | return p.then((app) => Promise.all([ 16 | getKeywords(app.title), 17 | getKeywords(`${app.summary || ''} ${app.description}`) 18 | ])) 19 | .then((keywords) => keywords[0].concat(R.difference(keywords[1], keywords[0]))); 20 | }; 21 | } 22 | 23 | module.exports = build; 24 | -------------------------------------------------------------------------------- /lib/scores/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const getTraffic = require('./traffic'); 4 | const getDifficulty = require('./difficulty'); 5 | 6 | function build (store) { 7 | return function (keyword) { 8 | keyword = keyword.toLowerCase(); 9 | return store 10 | .search({term: keyword, num: 100, fullDetail: true}) 11 | .then((apps) => Promise.all([ 12 | getDifficulty(store)(keyword, apps), 13 | getTraffic(store)(keyword, apps) 14 | ])) 15 | .then((results) => ({ 16 | difficulty: results[0], 17 | traffic: results[1] 18 | })); 19 | }; 20 | } 21 | 22 | module.exports = build; 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aso", 3 | "version": "1.1.2", 4 | "description": "Tools for app store optimization on iTunes and Google Play", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "Facundo Olano", 10 | "license": "ISC", 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/facundoolano/aso" 14 | }, 15 | "dependencies": { 16 | "app-store-scraper": "^0.16.3", 17 | "google-play-scraper": "^8.0.2", 18 | "nlcst-to-string": "^1.1.0", 19 | "promise-log": "^0.1.0", 20 | "ramda": "^0.21.0", 21 | "retext": "^2.0.0", 22 | "retext-keywords": "^2.0.1" 23 | }, 24 | "devDependencies": { 25 | "eslint": "^2.8.0", 26 | "eslint-config-semistandard": "^6.0.1", 27 | "eslint-config-standard": "^5.1.0", 28 | "eslint-plugin-promise": "^1.1.0", 29 | "eslint-plugin-standard": "^1.3.2" 30 | } 31 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 LambdaClass 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/calc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const R = require('ramda'); 4 | 5 | function round (val) { 6 | return Math.round(val * 100) / 100; 7 | } 8 | 9 | // general score 10 | function score (min, max, value) { 11 | value = Math.min(max, value); 12 | value = Math.max(min, value); 13 | return round(1 + 9 * (value - min) / (max - min)); 14 | } 15 | 16 | // zero based score 17 | function zScore (max, value) { 18 | return score(0, max, value); 19 | } 20 | 21 | // inverted score (min = 10, max = 1) 22 | function iScore (min, max, value) { 23 | value = Math.min(max, value); 24 | value = Math.max(min, value); 25 | return round(1 + 9 * (max - value) / (max - min)); 26 | } 27 | 28 | // inverted, zero based score 29 | function izScore (max, value) { 30 | return iScore(0, max, value); 31 | } 32 | 33 | // weighted aggregate score 34 | function aggregate (weights, values) { 35 | const max = 10 * R.sum(weights); 36 | const min = 1 * R.sum(weights); 37 | const sum = R.sum(R.zipWith(R.multiply, weights, values)); 38 | return score(min, max, sum); 39 | } 40 | 41 | module.exports = { 42 | round, 43 | score, 44 | zScore, 45 | iScore, 46 | izScore, 47 | aggregate 48 | }; 49 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('promise-log')(Promise); 4 | const R = require('ramda'); 5 | 6 | const STORES = { 7 | 'gplay': require('./lib/stores/gplay'), 8 | 'itunes': require('./lib/stores/itunes') 9 | }; 10 | 11 | const constants = require('./lib/constants'); 12 | 13 | const buildApp = require('./lib/app'); 14 | const buildScores = require('./lib/scores'); 15 | const buildSuggest = require('./lib/suggest'); 16 | const buildVisibility = require('./lib/visibility'); 17 | 18 | 19 | function getClient (store, opts) { 20 | // not forcing store to be a string anymore, in case someone wanats to pass 21 | // a homebrew object, e.g. to disable memoization 22 | 23 | if (R.is(String, store)) { 24 | opts = Object.assign({ 25 | country: 'us', 26 | throttle: 20 27 | }, opts); 28 | if (!(store in STORES)) { 29 | throw Error(`the store name should be one of: ${Object.keys(STORES).join(', ')}`); 30 | } 31 | 32 | store = STORES[store](opts); 33 | } 34 | 35 | return Object.assign({ 36 | app: buildApp(store), 37 | scores: buildScores(store), 38 | suggest: buildSuggest(store), 39 | visibility: buildVisibility(store) 40 | }, constants); 41 | } 42 | 43 | module.exports = getClient; 44 | -------------------------------------------------------------------------------- /lib/suggest/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const R = require('ramda'); 4 | const buildGetAppKeywords = require('../app'); 5 | const getStrategies = require('./strategies'); 6 | const c = require('../constants'); 7 | 8 | function build (store) { 9 | const strategies = getStrategies(store); 10 | 11 | /* 12 | * Return the proper app list promise based on the requested suggest strategy. 13 | */ 14 | function resolveApps (opts) { 15 | const handler = strategies[opts.strategy]; 16 | if (!handler) { 17 | throw Error('invalid suggestion strategy'); 18 | } 19 | return handler(opts); 20 | } 21 | 22 | /* Returns a rejector fn to exclude seed keywords from results. */ 23 | const rejectKeywords = (seeds) => R.reject((kw) => R.contains(kw, seeds)); 24 | 25 | /* 26 | * Return the most common keywords among the given apps. 27 | */ 28 | const getSuggestions = (apps, seeds) => 29 | Promise.all(apps.map(buildGetAppKeywords(store))) 30 | .then(R.unnest) 31 | .then(rejectKeywords(seeds)) 32 | .then(R.countBy(R.identity)) 33 | .then(R.toPairs) 34 | .then(R.sortBy((pair) => -pair[1])) 35 | .then(R.map(R.prop(0))); 36 | 37 | /* 38 | * Suggest keywords based on other apps selected according to a given strategy. 39 | */ 40 | function suggest (opts) { 41 | opts.strategy = opts.strategy || c.CATEGORY; 42 | const num = opts.num || 30; 43 | return resolveApps(opts) 44 | .then((apps) => getSuggestions(apps, opts.keywords || [])) 45 | .then(R.slice(0, num)); 46 | } 47 | 48 | return suggest; 49 | } 50 | 51 | module.exports = build; 52 | -------------------------------------------------------------------------------- /lib/retext.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const retext = require('retext'); 4 | const nlcstToString = require('nlcst-to-string'); 5 | const retextKeywords = require('retext-keywords'); 6 | const R = require('ramda'); 7 | 8 | const processor = retext().use(retextKeywords, {maximum: 20}); 9 | 10 | /* 11 | * Process the given text with retext and return the promise of an object with 12 | * the keywords and keyphrases extracted. 13 | */ 14 | function processKeywords (text) { 15 | // for some reason retext is not filtering "it's" out 16 | const cleanText = text.replace(/'t/g, '') 17 | .replace(/'s/g, '') 18 | .replace(/'ll/g, '') 19 | .replace(/'re/g, '') 20 | .replace(/'ve/g, ''); 21 | 22 | return new Promise(function (resolve, reject) { 23 | processor.process(cleanText, function (err, file) { 24 | if (err) { 25 | reject(err); 26 | } 27 | const space = file.namespace('retext'); 28 | const words = space.keywords.map((w) => ({ 29 | value: nlcstToString(w.matches[0].node), 30 | score: w.score 31 | })); 32 | const phrases = space.keyphrases.map((p) => ({ 33 | value: p.matches[0].nodes.map(nlcstToString).join(''), 34 | score: p.score 35 | })); 36 | 37 | resolve({words, phrases}); 38 | }); 39 | }); 40 | } 41 | 42 | const fixScore = (phrase) => R.assoc('score', phrase.score * 2.5, phrase); 43 | const isShortPhrase = (phrase) => phrase.value.split(' ').length <= 3; 44 | const notCharWord = (word) => word.value.length > 1; // removes 'i' 45 | const toLower = (word) => R.assoc('value', word.value.toLowerCase(), word); 46 | 47 | function getKeywords (text) { 48 | return processKeywords(text).then(function (results) { 49 | const phrases = results.phrases 50 | .filter(isShortPhrase) 51 | .map(toLower) 52 | .map(fixScore); 53 | 54 | const words = R.differenceWith(R.eqProps('value'), results.words.map(toLower), phrases) 55 | .concat(phrases) 56 | .filter(notCharWord); 57 | 58 | return R.sortBy((word) => -word.score, words) 59 | .map(R.prop('value')); 60 | }); 61 | } 62 | 63 | module.exports = getKeywords; 64 | -------------------------------------------------------------------------------- /lib/stores/itunes.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // FIXME don't force memoization 4 | const itunes = require('app-store-scraper').memoized(); 5 | const R = require('ramda'); 6 | const calc = require('../calc'); 7 | const debug = require('debug')('aso'); 8 | 9 | /* 10 | * An object that holds all store-specific parts of the algorithms exposed by 11 | * the library. This is not the most elegant solution ever, but beats introducing 12 | * hierarchies and inheritance. If these objects grow too big it's probably better 13 | * to break them into more cohessive components, maybe with defaults for the 14 | * common stuff. 15 | */ 16 | 17 | const getCollection = (app) => app.free ? itunes.collection.TOP_FREE_IOS : itunes.collection.TOP_PAID_IOS; 18 | const getGenre = (app) => app.primaryGenreId; 19 | 20 | function buildStore (defaults) { 21 | const wrapped = (method) => (opts) => { 22 | if (opts.appId && !R.identical(NaN, parseInt(opts.appId))) { 23 | opts.id = opts.appId; 24 | delete opts.appId; 25 | } 26 | const mergedOpts = R.merge(defaults, opts); 27 | debug('Calling app-store-scraper', method, JSON.stringify(mergedOpts)); 28 | return itunes[method](mergedOpts); 29 | }; 30 | 31 | const store = { 32 | MAX_SEARCH: 200, 33 | MAX_LIST: 100, 34 | 35 | list: wrapped('list'), 36 | search: wrapped('search'), 37 | similar: wrapped('similar'), 38 | app: wrapped('app'), 39 | suggest: (opts) => wrapped('suggest')(opts).then(R.pluck('term')), 40 | 41 | getInstallsScore: function (apps) { 42 | const avg = R.sum(apps.map((app) => app.reviews || 0)) / apps.length; 43 | const max = 100000; 44 | const score = calc.zScore(max, avg); 45 | return {avg, score}; 46 | }, 47 | 48 | getSuggestScore: (keyword) => wrapped('suggest')({term: keyword}) 49 | .then(R.find(R.propEq('term', keyword))) 50 | .then((result) => ({ 51 | score: calc.zScore(8000, result ? result.priority : 0) // max is actually 10k, but too few apps meet it 52 | })), 53 | 54 | getCollection, 55 | getGenre, 56 | getCollectionQuery: (app) => ({ 57 | collection: getCollection(app), 58 | category: getGenre(app), 59 | num: store.MAX_LIST 60 | }) 61 | }; 62 | 63 | return store; 64 | } 65 | 66 | module.exports = buildStore; 67 | -------------------------------------------------------------------------------- /lib/stores/gplay.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // FIXME don't force memoization 4 | const gplay = require('google-play-scraper').memoized(); 5 | const R = require('ramda'); 6 | const calc = require('../calc'); 7 | const debug = require('debug')('aso'); 8 | 9 | /* 10 | * An object that holds all store-specific parts of the algorithms exposed by 11 | * the library. This is not the most elegant solution ever, but beats introducing 12 | * hierarchies and inheritance. If these objects grow too big it's probably better 13 | * to break them into more cohessive components, maybe with defaults for the 14 | * common stuff. 15 | */ 16 | 17 | const MAX_KEYWORD_LENGTH = 25; 18 | 19 | const getCollection = (app) => app.free ? gplay.collection.TOP_FREE : gplay.collection.TOP_PAID; 20 | const getGenre = (app) => app.genreId; 21 | 22 | function buildStore (defaults) { 23 | const wrapped = (method) => (opts) => { 24 | const mergedOpts = R.merge(defaults, opts); 25 | debug('Calling google-store-scraper', method, JSON.stringify(mergedOpts)); 26 | return gplay[method](mergedOpts); 27 | }; 28 | 29 | function getSuggestLength (keyword, length) { 30 | length = length || 1; 31 | if (length > Math.min(keyword.length, MAX_KEYWORD_LENGTH)) { 32 | return Promise.resolve({ 33 | length: undefined, 34 | index: undefined 35 | }); 36 | } 37 | 38 | const prefix = keyword.slice(0, length); 39 | return wrapped('suggest')({term: prefix}) 40 | .then(function (suggestions) { 41 | const index = suggestions.indexOf(keyword); 42 | if (index === -1) { 43 | return getSuggestLength(keyword, length + 1); 44 | } 45 | return { length, index }; 46 | }); 47 | } 48 | 49 | const store = { 50 | MAX_SEARCH: 250, 51 | MAX_LIST: 120, 52 | 53 | list: wrapped('list'), 54 | search: wrapped('search'), 55 | app: wrapped('app'), 56 | similar: wrapped('similar'), 57 | suggest: wrapped('suggest'), 58 | 59 | getInstallsScore: function (apps) { 60 | const avg = R.sum(R.pluck('minInstalls', apps)) / apps.length; 61 | const max = 1000000; 62 | const score = calc.zScore(max, avg); 63 | return {avg, score}; 64 | }, 65 | 66 | getSuggestScore: (keyword) => getSuggestLength(keyword) 67 | .then(function (lengthStats) { 68 | let score; 69 | if (!lengthStats.length) { 70 | score = 1; 71 | } else { 72 | const lengthScore = calc.iScore(1, MAX_KEYWORD_LENGTH, lengthStats.length); 73 | const indexScore = calc.izScore(4, lengthStats.index); 74 | score = calc.aggregate([10, 1], [lengthScore, indexScore]); 75 | } 76 | return R.assoc('score', score, lengthStats); 77 | }), 78 | 79 | getCollection, 80 | getGenre, 81 | getCollectionQuery: (app) => ({ 82 | collection: getCollection(app), 83 | category: getGenre(app), 84 | num: store.MAX_LIST 85 | }) 86 | }; 87 | 88 | return store; 89 | } 90 | 91 | module.exports = buildStore; 92 | -------------------------------------------------------------------------------- /lib/visibility.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const R = require('ramda'); 4 | const buildGetKeywords = require('./app'); 5 | const buildGetTraffic = require('./scores/traffic'); 6 | const calc = require('./calc'); 7 | 8 | const findRank = (list, app) => (R.pluck('appId', list).indexOf(app.appId) + 1) || undefined; 9 | const rankScore = (weight, listSize, rank) => rank ? calc.round(weight * calc.iScore(1, listSize, rank)) : 0; 10 | 11 | function build (store) { 12 | const getTraffic = buildGetTraffic(store); 13 | const getKeywords = buildGetKeywords(store); 14 | 15 | const buildKeywordScores = (ranks, trafficStats) => trafficStats.map((traffic, index) => ({ 16 | traffic: traffic.score, 17 | rank: ranks[index], 18 | score: rankScore(traffic.score, store.MAX_SEARCH, ranks[index]) 19 | })); 20 | 21 | const keywordsToLists = (kws) => Promise.all( 22 | kws.map((kw) => store.search({ 23 | term: kw, 24 | num: store.MAX_SEARCH 25 | }))); 26 | 27 | /* 28 | * Get the app's rank for each of its top 20 keywords, and build a score based 29 | * on the rank and the keyword traffic. 30 | */ 31 | const getKeywordScores = (app) => 32 | getKeywords(app) 33 | .then(R.slice(0, 20)) 34 | .then((kws) => 35 | keywordsToLists(kws) 36 | .then((lists) => R.zipObj(kws, lists.map((list) => ({rank: findRank(list, app), list}))))) 37 | .then(R.reject((kwObj) => R.isNil(kwObj.rank))) 38 | .then((kwObjs) => { 39 | const promises = R.values(R.mapObjIndexed((obj, kw) => getTraffic(kw, obj.list), kwObjs)); 40 | return Promise.all(promises) 41 | .then((trafficStats) => buildKeywordScores(R.pluck('rank', R.values(kwObjs)), trafficStats)) 42 | .then(R.zipObj(R.keys(kwObjs))); 43 | }); 44 | 45 | /* 46 | * Get the rankings of the app in the top list and top category list and score 47 | * them. 48 | */ 49 | function getCollectionScores (app) { 50 | const categoryQuery = store.getCollectionQuery(app); 51 | const globalQuery = R.dissoc('category', categoryQuery); 52 | 53 | return Promise.all([ 54 | store.list(globalQuery), 55 | store.list(categoryQuery) 56 | ]) 57 | .then(R.map((list) => findRank(list, app))) 58 | .then((ranks) => ({ 59 | global: { 60 | rank: ranks[0], 61 | score: rankScore(100, store.MAX_LIST, ranks[0]) 62 | }, 63 | category: { 64 | rank: ranks[1], 65 | score: rankScore(10, store.MAX_LIST, ranks[1]) 66 | } 67 | })); 68 | } 69 | 70 | /* Build aggregate visibility score. */ 71 | const getVisbilityScore = (stats) => calc.round( 72 | R.sum(R.pluck('score', R.values(stats.keywords))) + 73 | stats.collections.global.score + 74 | stats.collections.category.score 75 | ); 76 | 77 | return (appId) => store.app({appId}) 78 | .then((app) => Promise.all([ 79 | getKeywordScores(app), 80 | getCollectionScores(app) 81 | ])) 82 | .then((scores) => ({ 83 | keywords: scores[0], 84 | collections: scores[1] 85 | })) 86 | .then((stats) => R.assoc('score', getVisbilityScore(stats), stats)); 87 | } 88 | 89 | module.exports = build; 90 | -------------------------------------------------------------------------------- /lib/scores/traffic.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const R = require('ramda'); 4 | const calc = require('../calc'); 5 | 6 | const MAX_KEYWORD_LENGTH = 25; 7 | 8 | // weights to merge all stats into a single score 9 | const SUGGEST_W = 8; 10 | const RANKED_W = 3; 11 | const INSTALLS_W = 2; 12 | const LENGTH_W = 1; 13 | 14 | function build (store) { 15 | /* 16 | * Score the length of the keyword (less traffic is assumed for longer keywords). 17 | */ 18 | function getKeywordLength (keyword) { 19 | const length = keyword.length; 20 | return { 21 | length, 22 | score: calc.iScore(1, MAX_KEYWORD_LENGTH, length) 23 | }; 24 | } 25 | 26 | /* 27 | * For each of the keyword's top apps, get the ranking for its category and check 28 | * what rank (if any) it has in that list. 29 | */ 30 | function getRankedApps (apps) { 31 | const findRank = (list, app) => (list.indexOf(app.appId) + 1) || undefined; 32 | 33 | const queries = R.uniq(apps.map(store.getCollectionQuery)); 34 | const queryIndex = queries.map((q) => [q.collection, q.category]); 35 | return Promise.all(queries.map(store.list)) 36 | .then(R.map(R.map(R.prop('appId')))) 37 | .then(R.zipObj(queryIndex)) 38 | .then(function (listMap) { 39 | // for each app, get its collection/category list and find its rank in there 40 | const findList = (app) => listMap[[store.getCollection(app), store.getGenre(app)]]; 41 | return apps.map((app) => findRank(findList(app), app)); 42 | }) 43 | .then(R.reject(R.isNil)) 44 | .then(function (results) { 45 | if (!results.length) { 46 | return {count: 0, avgRank: undefined, score: 1}; 47 | } 48 | 49 | const stats = { 50 | count: results.length, 51 | avgRank: R.sum(results) / results.length 52 | }; 53 | 54 | const countScore = calc.zScore(apps.length, stats.count); 55 | const avgRankScore = calc.iScore(1, 100, stats.avgRank); 56 | const score = calc.aggregate([5, 1], [countScore, avgRankScore]); 57 | return R.assoc('score', score, stats); 58 | }); 59 | } 60 | 61 | const getScore = (stats) => calc.aggregate( 62 | [SUGGEST_W, LENGTH_W, INSTALLS_W, RANKED_W], 63 | [stats.suggest.score, stats.length.score, stats.installs.score, stats.ranked.score] 64 | ); 65 | 66 | function getTopApps (apps) { 67 | const top = apps.slice(0, 10); 68 | // Ok, I admit it, totally afwul patch here, needed for visibility scores on 69 | // gplay, im getting the app detail here so I reduce a LOT of extra reqs that 70 | // would otherwise cause throttling issues at gplay. 71 | if (apps.length && !apps[0].description) { 72 | return Promise.all(top.map((app) => store.app({appId: app.appId}))); 73 | } else { 74 | return Promise.resolve(top); 75 | } 76 | } 77 | 78 | return (keyword, apps) => getTopApps(apps) 79 | .then((topApps) => Promise.all([ 80 | getRankedApps(topApps), 81 | store.getSuggestScore(keyword) 82 | ]) 83 | .then(function (results) { 84 | const ranked = results[0]; 85 | const suggest = results[1]; 86 | 87 | return { 88 | suggest, 89 | ranked, 90 | installs: store.getInstallsScore(topApps), 91 | length: getKeywordLength(keyword) 92 | }; 93 | }) 94 | .then((stats) => R.assoc('score', getScore(stats), stats))); 95 | } 96 | 97 | module.exports = build; 98 | -------------------------------------------------------------------------------- /lib/suggest/strategies.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const R = require('ramda'); 4 | const getKeywords = require('../retext'); 5 | const buildGetAppKeywords = require('../app'); 6 | const c = require('../constants'); 7 | 8 | const withoutApp = (appId) => R.reject((app) => app.appId === appId || app.id === parseInt(appId)); 9 | 10 | function build (store) { 11 | /* 12 | * Give an list of keywords, return the top 10 apps for each one. 13 | */ 14 | const getAppsFromKeywords = (keywords) => 15 | Promise.resolve(keywords) 16 | .then(R.map((kw) => ({ 17 | term: kw, 18 | num: 10, 19 | fullDetail: true 20 | }))) 21 | .then(R.map(store.search)) 22 | .then((promises) => Promise.all(promises)) 23 | .then(R.unnest); 24 | 25 | /* 26 | * Given a list of terms, get their search completion suggestions and extract 27 | * the keywords they contain. 28 | */ 29 | const getSearchKeywords = (terms) => 30 | Promise.all(terms.map(term => store.suggest({term}))) 31 | .then(R.map(R.slice(0, 15))) // up to 15 suggestions per seed kw 32 | .then(R.unnest) 33 | .then((suggestions) => Promise.all(suggestions.map(getKeywords))) // break suggestions into kws 34 | .then(R.unnest) 35 | .then(R.uniq); 36 | 37 | /* 38 | * Given an appId, return a list of apps considered similar by the store. 39 | */ 40 | const similar = (opts) => store.similar({appId: opts.appId, fullDetail: true}); 41 | 42 | /* 43 | * Given an appId, return the list of top apps of its category/collection. 44 | */ 45 | const category = (opts) => 46 | store.app({appId: opts.appId}) 47 | .then(store.getCollectionQuery) 48 | .then(R.assoc('fullDetail', true)) 49 | .then(store.list) 50 | .then(withoutApp(opts.appId)); 51 | 52 | /* 53 | * Given an appId, get the its top 10 keywords and return the top apps for each 54 | * of them. 55 | */ 56 | const competition = (opts) => 57 | buildGetAppKeywords(store)(opts.appId) 58 | .then(R.slice(0, 10)) 59 | .then(getAppsFromKeywords) 60 | .then(withoutApp(opts.appId)); 61 | 62 | /* 63 | * Given an array of appIds, return the list of apps matchinf those ids. 64 | */ 65 | function arbitrary (opts) { 66 | if (!R.is(Array, opts.apps)) { 67 | return Promise.reject(Error('an appId array is required for arbitrary suggestions')); 68 | } 69 | return Promise.all(opts.apps.map((appId) => store.app({appId}))); 70 | } 71 | 72 | /* 73 | * Given an array of keywords, return the list of top apps for those keywords. 74 | */ 75 | function keywords (opts) { 76 | if (!R.is(Array, opts.keywords)) { 77 | return Promise.reject(Error('an array of seed keywords is required for this strategy')); 78 | } 79 | return getAppsFromKeywords(opts.keywords); 80 | } 81 | 82 | /* 83 | * Given an array of seed keywords, get related keywords based on search 84 | * suggestions, then return a list of top apps for them. 85 | */ 86 | function search (opts) { 87 | if (!R.is(Array, opts.keywords)) { 88 | return Promise.reject(Error('an array of seed keywords is required for this strategy')); 89 | } 90 | return getSearchKeywords(opts.keywords).then(getAppsFromKeywords); 91 | } 92 | 93 | const strategies = {}; 94 | strategies[c.SIMILAR] = similar; 95 | strategies[c.COMPETITION] = competition; 96 | strategies[c.CATEGORY] = category; 97 | strategies[c.ARBITRARY] = arbitrary; 98 | strategies[c.KEYWORDS] = keywords; 99 | strategies[c.SEARCH] = search; 100 | 101 | return strategies; 102 | } 103 | 104 | module.exports = build; 105 | -------------------------------------------------------------------------------- /lib/scores/difficulty.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const R = require('ramda'); 4 | const buildGetKeywords = require('../app'); 5 | const calc = require('../calc'); 6 | 7 | // weights to merge all stats into a single score 8 | const TITLE_W = 4; 9 | const COMPETITOR_W = 3; 10 | const INSTALLS_W = 5; 11 | const RATING_W = 2; 12 | const AGE_W = 1; 13 | 14 | function build (store) { 15 | function getMatchType (keyword, title) { 16 | keyword = keyword.toLowerCase(); 17 | title = title.toLowerCase(); 18 | 19 | if (title.includes(keyword)) { 20 | return 'exact'; 21 | } 22 | const matches = keyword.split(' ').map((word) => title.includes(word)); 23 | if (R.all(R.identity, matches)) { 24 | return 'broad'; 25 | } 26 | if (R.any(R.identity, matches)) { 27 | return 'partial'; 28 | } 29 | return 'none'; 30 | } 31 | 32 | /* 33 | * Score the amount of exact, broad, partial and none matches for the keyword in 34 | * the given apps titles. 35 | */ 36 | function getTitleMatches (keyword, apps) { 37 | const matches = R.pluck('title', apps).map((app) => getMatchType(keyword, app)); 38 | const counts = { 39 | exact: R.filter(R.equals('exact'), matches).length, 40 | broad: R.filter(R.equals('broad'), matches).length, 41 | partial: R.filter(R.equals('partial'), matches).length, 42 | none: R.filter(R.equals('none'), matches).length 43 | }; 44 | 45 | const score = (10 * counts.exact + 5 * counts.broad + 2.5 * counts.partial) / apps.length; 46 | return R.assoc('score', score, counts); 47 | } 48 | 49 | function isCompetitor (keyword, app) { 50 | return buildGetKeywords(store)(app).then((kws) => R.contains(keyword, kws.slice(0, 10))); 51 | } 52 | 53 | /* 54 | * Score the amount apps that have the keyword as one of their top keywords in 55 | * their description. 56 | */ 57 | function getCompetitors (keyword, apps) { 58 | return Promise.all(apps.map((app) => isCompetitor(keyword, app))) 59 | .then(R.filter(R.identity)) 60 | .then(R.length) 61 | .then((count) => ({count, score: calc.zScore(apps.length, count)})); 62 | } 63 | 64 | /* 65 | * Score the average rating among the top apps. 66 | */ 67 | function getRating (keyword, apps) { 68 | const avg = R.sum(apps.map((app) => app.score || 0)) / apps.length; 69 | return { 70 | avg, 71 | score: avg * 2 72 | }; 73 | } 74 | 75 | function getDaysSince (date) { 76 | if (typeof date === 'string') { 77 | date = Date.parse(date); 78 | } else { 79 | date = date / 1000; 80 | } 81 | return Math.floor((Date.now() - date) / 86400000); 82 | } 83 | 84 | /* 85 | * Score the average time since last update among the top apps. 86 | */ 87 | function getAge (keyword, apps) { 88 | // FIXME this is a number in google play now 89 | const updated = R.pluck('updated', apps).map(getDaysSince); 90 | const avg = R.sum(updated) / apps.length; 91 | const max = 500; 92 | const score = calc.izScore(max, avg); 93 | 94 | return { 95 | avgDaysSinceUpdated: avg, 96 | score 97 | }; 98 | } 99 | 100 | /* 101 | * Calculate an aggregate score according to each stat's weight. 102 | */ 103 | function getScore (stats) { 104 | return calc.aggregate([TITLE_W, COMPETITOR_W, INSTALLS_W, RATING_W, AGE_W], 105 | [stats.titleMatches.score, stats.competitors.score, 106 | stats.installs.score, stats.rating.score, stats.age.score]); 107 | } 108 | 109 | /* 110 | * Return the promise of an object with stats and scores of the difficulty of 111 | * the given keyword. 112 | */ 113 | return function (keyword, apps) { 114 | return getCompetitors(keyword, apps) // gimme freakin destructuring 115 | .then(function (competitors) { 116 | const topApps = apps.slice(0, 10); 117 | return { 118 | titleMatches: getTitleMatches(keyword, topApps), 119 | competitors, 120 | installs: store.getInstallsScore(topApps), 121 | rating: getRating(keyword, topApps), 122 | age: getAge(keyword, topApps) 123 | }; 124 | }) 125 | .then((stats) => R.assoc('score', getScore(stats), stats)); 126 | }; 127 | } 128 | 129 | module.exports = build; 130 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # App Store Optimization (aso) 2 | 3 | This Node.js library provides a set of functions to aid [App Store Optimization](https://en.wikipedia.org/wiki/App_store_optimization) of applications in iTunes and Google Play. 4 | 5 | The functions use either [google-play-scraper](https://github.com/facundoolano/google-play-scraper) 6 | or [app-store-scraper](https://github.com/facundoolano/app-store-scraper) to 7 | gather data, so bear in mind a lot of requests are performed under the hood 8 | and you may hit throttling limits when making too many calls in a short period of time. 9 | 10 | * [Installation](#installation) 11 | * [API reference](#api-reference) 12 | * [Keyword Scores](#keyword-scores) 13 | * [Difficulty](#difficulty) 14 | * [Traffic](#traffic) 15 | * [Keyword suggestions](#keyword-suggestions) 16 | * [Suggestions by category](#suggestions-by-category) 17 | * [Suggestions by similarity](#suggestions-by-similarity) 18 | * [Suggestions by competition](#suggestions-by-competition) 19 | * [Suggestions by an arbitrary list of apps](#suggestions-by-an-arbitrary-list-of-apps) 20 | * [Suggestions based on seed keywords](#suggestions-based-on-seed-keywords) 21 | * [Suggestions based on search hints](#suggestions-based-on-search-hints) 22 | * [App visibility score](#app-visibility-score) 23 | * [App Keywords](#app-keywords) 24 | * [A note on keyword relevancy for iTunes](#a-note-on-keyword-relevancy-for-itunes) 25 | * [Store backend configuration](#store-backend-configuration) 26 | 27 | 28 | ## Installation 29 | 30 | ``` 31 | npm install aso 32 | ``` 33 | 34 | ## API Reference 35 | 36 | The module exports a function to build a client that will query either iTunes (`'itunes'`) 37 | or Google Play (`'gplay'`): 38 | 39 | ```js 40 | const gplay = require('aso')('gplay'); 41 | const itunes = require('aso')('itunes'); 42 | 43 | // do stuff with google play 44 | gplay.scores('panda').then(console.log); 45 | 46 | // do stuff with itunes 47 | itunes.scores('panda').then(console.log); 48 | ``` 49 | 50 | The behaviour of the algorithms is the same for both stores, except where noted. 51 | 52 | ### Keyword scores 53 | 54 | The `scores` function gathers several statistics about a keyword and builds 55 | `difficulty` and `traffic` scores that can be used to evaluate the 56 | convenience of targeting that keyword. 57 | 58 | The only argument is the keyword itself: 59 | 60 | ```js 61 | const aso = require('aso')('gplay'); 62 | 63 | aso.scores('panda').then(console.log) 64 | ``` 65 | 66 | Returns: 67 | 68 | ```js 69 | { difficulty: 70 | { titleMatches: { exact: 10, broad: 0, partial: 0, none: 0, score: 10 }, 71 | competitors: { count: 33, score: 5.95 }, 72 | installs: { avg: 2470000, score: 10 }, 73 | rating: { avg: 4.04, score: 8.08 }, 74 | age: { avgDaysSinceUpdated: 81.4, score: 8.53 }, 75 | score: 8.84 }, 76 | traffic: 77 | { suggest: { length: 3, index: 3, score: 8.7 }, 78 | ranked: { count: 5, avgRank: 52.2, score: 5.48 }, 79 | installs: { avg: 2470000, score: 10 }, 80 | length: { length: 5, score: 8.5 }, 81 | score: 8.18 } } 82 | ``` 83 | 84 | Scores are calculated as linear functions and aggregated with somewhat arbitrary 85 | weights. All statistics are included in the response to allow custom scoring 86 | functions to be used. 87 | 88 | Any suggestions on how to tune or improve the score calculations are welcome :) 89 | 90 | #### Difficulty 91 | 92 | The difficulty of a keyword measures how hard it is to rank high on searches for 93 | that kewyord. This is usually the most important aspect to consider when picking 94 | a keyword (after relevance of the keyword for the given app). The lower this score, 95 | the better the candidate keyword. 96 | 97 | The properties considered for this score are: 98 | 99 | * `titleMatches`: classifies the titles of the top 10 apps for the keyword according 100 | to how well they match the words that make it: exact (contains all the words, in the same order), 101 | broad (contains all the words in a different order), partial (contains some of the 102 | words), none (does not contain any of the words). 103 | * `competitors`: counts how many of the top 100 apps for the keyword actually 104 | target that keyword in their title and description. 105 | * `installs`: measures the average amount of installs of the top 10 apps. Since iTunes 106 | does not expose the amount of installs, the reviews count is used instead. 107 | * `rating`: measures the average rating of the top 10 apps. 108 | * `age`: measures the average time since the apps in the top 10 have been updated. 109 | 110 | #### Traffic 111 | 112 | The traffic score estimates how much traffic that keyword gets. Note this factor 113 | is better considered after picking keywords with high relevance and low difficulty. 114 | A high score means high traffic and therefore a better keyword candidate. 115 | 116 | The properties considered for this score are: 117 | 118 | * `suggest`: For Google Play the amount of characters needed for the keyword to come up as a 119 | suggestion in the search box, and the position in the suggestions list. iTunes already 120 | scores their suggest results, so that number is used instead. 121 | * `ranked`: the amount of apps in the top 10 of the keyword that appear in their 122 | category rankings, and the average ranking of those that do. 123 | * `installs`: same metric as in difficulty, but with a lower weight in the overall score. 124 | * `length`: length of the keyword (less traffic is assumed for longer keywords). 125 | 126 | ### Keyword suggestions 127 | 128 | The `suggest` function returns a list of suggestions consisting 129 | of the most commonly used keywords among a given set of apps. There are several 130 | strategies to select that set of apps. 131 | 132 | This function takes an options object with the following properties: 133 | * `strategy`: the strategy used to get suggestions. Defaults to `CATEGORY`. 134 | * `num`: the amount of suggestions to get in the results. Defaults to 30. 135 | * `appId`: store app ID (for iTunes both numerical and bundle IDs are supported). 136 | Required for the `CATEGORY`, `SIMILAR` and `COMPETITION` strategies. 137 | * `apps`: array of store app IDs. Required for the `ARBITRARY` strategy. 138 | * `keywords`: array of seed keywords. Required for the `KEYWORDS` and `SEARCH` strategies. 139 | 140 | A common flow of work would be to try all the strategies for a given app, hand pick the most interesting 141 | keywords and then run the `scores` function on them to analize their quality. 142 | 143 | #### Suggestions by category 144 | Looks at apps in the same category as the one given. 145 | 146 | ```js 147 | const aso = require('aso')('gplay'); 148 | 149 | aso.suggest({ 150 | strategy: aso.CATEGORY, 151 | appId: 'com.dxco.pandavszombies', 152 | num: 5}) 153 | .then(console.log); 154 | ``` 155 | 156 | Returns: 157 | ```js 158 | [ 'game', 'world', 'features', 'weapons', 'action' ] 159 | ``` 160 | 161 | #### Suggestions by similarity 162 | Looks at apps marked by Google Play as "similar". For iTunes the "customers also bought" apps are used instead (which may not necessarily be similar to the given app). 163 | 164 | ```js 165 | const aso = require('aso')('gplay'); 166 | 167 | aso.suggest({ 168 | strategy: aso.SIMILAR, 169 | appId: 'com.dxco.pandavszombies', 170 | num: 5}) 171 | .then(console.log); 172 | ``` 173 | 174 | Returns: 175 | ```js 176 | [ 'game', 'zombies', 'zombie', 'weapons', 'action' ] 177 | ``` 178 | 179 | #### Suggestions by competition 180 | Looks at apps that target the same keywords as the one given. 181 | 182 | ```js 183 | const aso = require('aso')('gplay'); 184 | 185 | aso.suggest({ 186 | strategy: aso.COMPETITION, 187 | appId: 'com.dxco.pandavszombies', 188 | num: 5}) 189 | .then(console.log); 190 | ``` 191 | 192 | Returns: 193 | ```js 194 | [ 'game', 'zombies', 'features', 'app', 'zombie' ] 195 | ``` 196 | 197 | #### Suggestions by an arbitrary list of apps 198 | 199 | ```js 200 | const aso = require('aso')('gplay'); 201 | 202 | aso.suggest({ 203 | strategy: aso.ARBITRARY, 204 | apps: ['com.dxco.pandavszombies'], 205 | num: 5}) 206 | .then(console.log); 207 | ``` 208 | 209 | Returns: 210 | ```js 211 | [ 'game', 'zombies', 'features', 'app', 'zombie' ] 212 | ``` 213 | 214 | #### Suggestions based on seed keywords 215 | Look at apps that target one of the given seed keywords. 216 | 217 | ```js 218 | const aso = require('aso')('gplay'); 219 | 220 | aso.suggest({ 221 | strategy: aso.KEYWORDS, 222 | keywords: ['panda', 'zombies', 'hordes'], 223 | num: 5}) 224 | .then(console.log); 225 | ``` 226 | 227 | Returns: 228 | ```js 229 | [ 'features', 'game', 'zombies', 'panda', 'zombie' ] 230 | ``` 231 | 232 | #### Suggestions based on search hints 233 | Given a set of seed keywords, infer a new set from the search completion suggestions of each one. Then look at apps that target the resulting keywords. This is expected to work better for iTunes, where the search completion yields more 234 | results. 235 | 236 | ```js 237 | const aso = require('aso')('gplay'); 238 | 239 | aso.suggest({ 240 | strategy: aso.SEARCH, 241 | keywords: ['panda', 'zombies', 'hordes'], 242 | num: 5}) 243 | .then(console.log); 244 | ``` 245 | 246 | Returns: 247 | ```js 248 | [ 'game', 'features', 'zombie', 'zombies', 'way' ] 249 | ``` 250 | 251 | ### App visibility score 252 | 253 | The `visibility` function gives an estimation of the app's discoverability within 254 | the store. The scores are built aggregating how well the app ranks for its target 255 | keywords, the traffic score for those keywords and how the app ranks in the 256 | top global and category rankings. 257 | 258 | The only argument to the function is the App ID (package id for Google Play and 259 | either numerical or bundle ID for iTunes). 260 | 261 | Google Play example: 262 | ```js 263 | const aso = require('aso')('gplay'); 264 | 265 | aso.visibility('com.dxco.pandavszombies').then(console.log); 266 | ``` 267 | 268 | Returns: 269 | 270 | ```js 271 | { keywords: 272 | { 'panda vs zombies': { traffic: 2.94, rank: 1, score: 29.4 }, 273 | rocky: { traffic: 7.81, rank: 74, score: 57.48 }, 274 | 'panda vs zombie': { traffic: 3.49, rank: 8, score: 34.03 }, 275 | 'panda warrior': { traffic: 1.47, rank: 5, score: 14.49 }, 276 | 'zombie elvis': { traffic: 3.3, rank: 1, score: 33 }, 277 | meatloaf: { traffic: 5.79, rank: 16, score: 54.77 }, 278 | ftw: { traffic: 2.88, rank: 58, score: 22.87 } }, 279 | collections: 280 | { global: { rank: undefined, score: 0 }, 281 | category: { rank: undefined, score: 0 } }, 282 | score: 246.04 } 283 | ``` 284 | 285 | iTunes example: 286 | 287 | ```js 288 | const aso = require('aso')('gplay'); 289 | 290 | aso.visibility(284882215) // ID for the facebook app 291 | .then(console.log); 292 | ``` 293 | 294 | Returns: 295 | ```js 296 | { keywords: 297 | { facebook: { traffic: 9.55, rank: 1, score: 95.5 }, 298 | friends: { traffic: 7.21, rank: 2, score: 71.74 } }, 299 | collections: 300 | { global: { rank: 3, score: 991 }, 301 | category: { rank: 2, score: 99.5 } }, 302 | score: 1257.74 } 303 | ``` 304 | 305 | ### App keywords 306 | 307 | The `app` function returns an array of keywords extracted from title and description 308 | of the app. The only argument is the Google Play ID of the application (the `?id=` parameter on the url). 309 | 310 | ```js 311 | const aso = require('aso')('gplay'); 312 | 313 | aso.app('com.dxco.pandavszombies').then(console.log) 314 | ``` 315 | 316 | Returns: 317 | 318 | ```js 319 | [ 320 | 'panda', 321 | 'rocky', 322 | 'zombie', 323 | 'panda vs zombie', 324 | 'elvis', 325 | 'undead', 326 | 'time', 327 | 'game', 328 | 'vs', 329 | (...) 330 | ] 331 | ``` 332 | 333 | [retext-keywords](https://github.com/wooorm/retext-keywords) is used to extract the keywords 334 | from the app title and description. 335 | 336 | #### A note on keyword relevancy for iTunes 337 | 338 | As said, the algorithm used by the `app` function extracts the keywords from title and 339 | description. This algorithm is also used internally by the `scores` and 340 | `suggest` functions. 341 | 342 | While in all cases the most important place to look at for keywords is the title, 343 | the app description is usually less relevant in the iTunes app store, since there's 344 | a specific keywords list field when submitting the app. Unfortunately the contents 345 | of that field are not (that I know of) reachable from any public page or API. So 346 | keywords based on description may not have a big weight on iTunes searches. 347 | 348 | Google Play, on the other hand, doesn't have a keywords field and so the description is 349 | expected to contain most of the app's targeted keywords. 350 | 351 | ### Store backend configuration 352 | 353 | An object can be passed as a second argument to the client builder function, with 354 | options to override the behavior of [google-play-scraper](https://github.com/facundoolano/google-play-scraper) 355 | and [app-store-scraper](https://github.com/facundoolano/app-store-scraper). 356 | The given options will be included in every method call to the stores. 357 | This can be used, for example, to target a differnt country than the default `'us'`: 358 | 359 | ```js 360 | const itunesRussia = require('aso')('itunes', { country: 'ru' }); 361 | 362 | // do stuff with itunes 363 | itunesRussia.scores('panda').then(console.log); 364 | ``` 365 | 366 | Other options that may be useful are `cache` and `throttle`. See the reference 367 | of each scraper for all the available options. 368 | 369 | ### Note about Google Play performance 370 | 371 | While iTunes provides an API to search apps with all their details, getting data from Google Play usually requires making a request for the search and then additional requests to get the details for each resulting app, then parsing the HTML. This means that most of the functions of this module (specially scores) will be muchs slower for Google Play than for iTunes (taking even minutes). This is expected given that data is scraped from Google Play in real time on every call. This can be partially mitigated using memoization, at the expense of memory usage, but a better approach (outside the scope of this project) to get faster results would be to periodically scan Google Play, save the data to a database and query that for score calculations. 372 | --------------------------------------------------------------------------------