Commit b48a1af4 authored by Markus Shepherd's avatar Markus Shepherd

Merge branch 'bga' into 'master'

Resolve "BGA recommendations"

Closes #208

See merge request !76
parents e2cf28d8 556abb90
1-6-4
\ No newline at end of file
1-7-0
\ No newline at end of file
......@@ -101,6 +101,7 @@
<script src="/js/news.js" type="text/javascript"></script>
<script src="/js/about.js" type="text/javascript"></script>
<script src="/js/faq.js" type="text/javascript"></script>
<script src="/js/bga.js" type="text/javascript"></script>
<link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32x32.png" />
......
......@@ -20,7 +20,8 @@ ludojApp.constant('API_URL', '/api/')
.constant('SITE_DESCRIPTION', 'Top-rated board games as evaluated by our recommendation engine. ' +
'Find the best board and card games with personal recommendations for your taste!')
.constant('GA_TRACKING_ID', 'UA-128891980-1')
.constant('FAQ_URL', '/assets/faq.json');
.constant('FAQ_URL', '/assets/faq.json')
.constant('BGA_CLIENT_ID', '8jfqHypg2l');
ludojApp.config(function (
$locationProvider,
......@@ -50,6 +51,9 @@ ludojApp.config(function (
}).when('/faq', {
templateUrl: '/partials/faq.html',
controller: 'FaqController'
}).when('/bga', {
templateUrl: '/partials/bga.html',
controller: 'BgaController'
}).when('/', {
templateUrl: '/partials/list.html',
controller: 'ListController'
......
/*jslint browser: true, nomen: true, stupid: true, todo: true */
/*jshint -W097 */
/*global ludojApp, _, $ */
'use strict';
ludojApp.controller('BgaController', function BgaController(
$location,
$log,
$http,
$q,
$route,
$routeParams,
$scope,
API_URL,
BGA_CLIENT_ID,
filterService,
gamesService,
toastr
) {
var users = {};
function fetchGames(page) {
toastr.clear();
page = _.parseInt(page) || $scope.page || $scope.nextPage || 1;
var append = page > 1,
url = API_URL + 'games/recommend_bga/',
userName = $routeParams.for || null,
params = {'page': page},
promise,
bgaParams,
games;
if (!userName) {
promise = $q.resolve(params);
} else if (users[userName]) {
params.user = users[userName];
promise = $q.resolve(params);
} else {
bgaParams = {
'client_id': BGA_CLIENT_ID,
'username': userName,
'limit': 1
};
promise = $http.get('https://www.boardgameatlas.com/api/reviews', {'params': bgaParams})
.then(function (response) {
var user = _.get(response, 'data.reviews[0].user.id') || null;
if (user) {
users[userName] = user;
params.user = user;
}
return params;
});
}
return promise
.then(function (params) {
$log.debug('query parameters', params);
return $http.get(url, {'params': params});
})
.then(function (response) {
response = response.data;
page = response.page || page;
$scope.currPage = page;
$scope.prevPage = response.previous ? page - 1 : null;
$scope.nextPage = response.next ? page + 1 : null;
$scope.total = response.count;
$scope.currUser = userName;
games = response.results;
var ids = _.map(games, 'bga_id');
bgaParams = {
'client_id': BGA_CLIENT_ID,
'ids': _.join(ids)
};
return $http.get('https://www.boardgameatlas.com/api/search', {'params': bgaParams});
})
.then(function (response) {
var bgaObj = _(response.data.games)
.map(function (game) {
return [game.id, game];
})
.fromPairs()
.value();
games = _(games)
.map(function (rg) {
var bga = bgaObj[rg.bga_id];
if (!bga || rg.bga_id !== bga.id) {
return null;
}
rg.rec_rank = rg.rank;
rg.rec_rating = rg.score;
rg.rec_stars = rg.stars;
rg.image_url = [bga.image_url];
rg.name = rg.name || bga.name;
rg.year = bga.year_published;
return rg;
})
.filter()
.value();
games = append && !_.isEmpty($scope.games) ? _.concat($scope.games, games) : games;
$scope.games = games;
$scope.empty = _.isEmpty(games) && !$scope.nextPage;
return games;
})
.catch(function (response) {
$log.error(response);
$scope.empty = false;
$scope.total = null;
toastr.error(
'Sorry, there was an error. Tap to try again...',
'Error loading games',
{'onTap': function onTap() {
return fetchGames(page);
}}
);
})
.then(function () {
$(function () {
$('.tooltip').remove();
$('[data-toggle~="tooltip"]').tooltip();
$('[data-toggle-tooltip~="tooltip"]').tooltip();
});
});
}
$scope.user = $routeParams.for;
$scope.similarity = $routeParams.similarity;
$scope.fetchGames = fetchGames;
$scope.empty = false;
$scope.total = null;
$scope.hideScore = $routeParams.for && $routeParams.similarity;
$scope.updateParams = function updateParams() {
$route.updateParams({'for': $scope.user || null});
};
$scope.clearField = function clearField(field, id) {
$scope[field] = null;
id = id || field;
$('#' + id).focus();
};
fetchGames(1);
gamesService.setTitle();
gamesService.setCanonicalUrl($location.path(), filterService.getParams($routeParams));
gamesService.setImage();
gamesService.setDescription();
});
<!DOCTYPE html>
<section xmlns="http://www.w3.org/1999/xhtml"
id="games-list">
<h1>Board Game Atlas recommendations</h1>
<form ng-submit="updateParams()"
id="fetch-game-form"
name="fetch-game-form"
novalidate="novalidate"
role="form"
aria-labelledby="bgg-tab"
class="form mb-3">
<label for="user"
id="bgg-user-label"
class="{{ user ? 'text-success' : 'text-muted' }} mb-0">
Personal recommendations for:
</label>
<div class="input-group">
<input type="search"
id="user"
name="user"
ng-model="user"
ng-required="true"
class="form-control"
placeholder="Board Game Atlas user name"
aria-describedby="bgg-user-label" />
<div class="input-group-append">
<button type="button"
ng-disabled="!user"
ng-click="clearField('user')"
class="btn btn-outline-secondary">
<i class="fas fa-times"></i>
</button>
<button type="submit"
ng-disabled="!user"
class="btn btn-primary">
<i class="fas fa-dice"></i>
</button>
</div>
</div>
</form>
<h2 ng-if="currUser">
Recommended games for <strong>
<a ng-href="https://www.boardgameatlas.com/u/{{ currUser }}"
target="_blank">{{ currUser }} <i class="fas fa-external-link-alt"></i></a>
</strong>
</h2>
<div ng-if="empty"
class="alert alert-warning"
role="alert">
<h4 class="alert-heading">No games found</h4>
<p class="mb-0">
No games could be loaded.
</p>
</div>
<div class="row">
<a ng-repeat="game in games"
game-square=""
game="game"
show-ranking="true"
hide-score="hideScore"
ng-href="https://www.boardgameatlas.com/search/game/{{ game.bga_id }}"
target="_blank"
class="col-lg-3 col-md-4 col-sm-6"></a>
<div ng-if="nextPage"
ng-click="fetchGames(nextPage)"
class="col-lg-3 col-md-4 col-sm-6">
<div class="game game-more">
<div>
<h2 class="game-title">
More...
<small ng-if="total"
class="text-secondary">
({{ total }} games in total)
</small>
</h2>
</div>
</div>
</div>
</div>
<script type="application/ld+json">
{
"@context": "http://schema.org",
"@type": "WebSite",
"url": "https://recommend.games/",
"potentialAction": [{
"@type": "SearchAction",
"target": "https://recommend.games/#/?search={search_term_string}",
"query-input": "required name=search_term_string"
}]
}
</script>
</section>
......@@ -358,31 +358,77 @@ def labellinks(
)
@task()
def train(
games_file=os.path.join(SCRAPED_DATA_DIR, 'scraped', 'bgg_GameItem.jl'),
ratings_file=os.path.join(SCRAPED_DATA_DIR, 'scraped', 'bgg_RatingItem.jl'),
out_path=os.path.join(RECOMMENDER_DIR, '.tc'),
def _train(
recommender_cls,
games_file,
ratings_file,
out_path=None,
users=None,
max_iterations=100,
):
''' train recommender model '''
from ludoj_recommender import BGGRecommender
LOGGER.info(
'Training recommender model with games <%s> and ratings <%s>...', games_file, ratings_file)
recommender = BGGRecommender.train_from_files(
'Training %r recommender model with games <%s> and ratings <%s>...',
recommender_cls, games_file, ratings_file)
recommender = recommender_cls.train_from_files(
games_file=games_file,
ratings_file=ratings_file,
similarity_model=True,
max_iterations=max_iterations,
verbose=True,
)
recommendations = recommender.recommend(users=users, num_games=100)
recommendations.print_rows(num_rows=100)
LOGGER.info('Saving model %r to <%s>...', recommender, out_path)
shutil.rmtree(out_path, ignore_errors=True)
recommender.save(out_path)
if out_path:
LOGGER.info('Saving model %r to <%s>...', recommender, out_path)
shutil.rmtree(out_path, ignore_errors=True)
recommender.save(out_path)
@task()
def trainbgg(
games_file=os.path.join(SCRAPED_DATA_DIR, 'scraped', 'bgg_GameItem.jl'),
ratings_file=os.path.join(SCRAPED_DATA_DIR, 'scraped', 'bgg_RatingItem.jl'),
out_path=os.path.join(RECOMMENDER_DIR, '.bgg'),
users=None,
max_iterations=1000,
):
''' train BoardGameGeek recommender model '''
from ludoj_recommender import BGGRecommender
_train(
recommender_cls=BGGRecommender,
games_file=games_file,
ratings_file=ratings_file,
out_path=out_path,
users=users,
max_iterations=max_iterations,
)
@task()
def trainbga(
games_file=os.path.join(SCRAPED_DATA_DIR, 'scraped', 'bga_GameItem.jl'),
ratings_file=os.path.join(SCRAPED_DATA_DIR, 'scraped', 'bga_RatingItem.jl'),
out_path=os.path.join(RECOMMENDER_DIR, '.bga'),
users=None,
max_iterations=1000,
):
''' train Board Game Atlas recommender model '''
from ludoj_recommender import BGARecommender
_train(
recommender_cls=BGARecommender,
games_file=games_file,
ratings_file=ratings_file,
out_path=out_path,
users=users,
max_iterations=max_iterations,
)
@task(trainbgg, trainbga)
def train():
''' train BoardGameGeek and Board Game Atlas recommender models '''
@task()
......@@ -394,7 +440,8 @@ def cleandata(src_dir=DATA_DIR, bk_dir=f'{DATA_DIR}.bk'):
shutil.rmtree(bk_dir, ignore_errors=True)
if os.path.exists(src_dir):
os.rename(src_dir, bk_dir)
os.makedirs(os.path.join(src_dir, 'recommender'))
os.makedirs(os.path.join(src_dir, 'recommender_bgg'))
os.makedirs(os.path.join(src_dir, 'recommender_bga'))
@task()
......@@ -407,7 +454,7 @@ def migrate():
@task(cleandata, migrate)
def filldb(
src_dir=SCRAPED_DATA_DIR,
rec_dir=os.path.join(RECOMMENDER_DIR, '.tc'),
rec_dir=os.path.join(RECOMMENDER_DIR, '.bgg'),
):
''' fill database '''
LOGGER.info(
......@@ -434,8 +481,8 @@ def compressdb(db_file=os.path.join(DATA_DIR, 'db.sqlite3')):
@task()
def cpdirs(
src_dir=os.path.join(RECOMMENDER_DIR, '.tc'),
dst_dir=os.path.join(DATA_DIR, 'recommender'),
src_dir=os.path.join(RECOMMENDER_DIR, '.bgg'),
dst_dir=os.path.join(DATA_DIR, 'recommender_bgg'),
sub_dirs=('recommender', 'similarity', 'clusters', 'compilations'),
):
''' copy recommender files '''
......@@ -447,6 +494,16 @@ def cpdirs(
shutil.copytree(src_path, dst_path)
@task()
def cpdirsbga(
src_dir=os.path.join(RECOMMENDER_DIR, '.bga'),
dst_dir=os.path.join(DATA_DIR, 'recommender_bga'),
sub_dirs=('recommender', 'similarity'),
):
''' copy BGA recommender files '''
cpdirs(src_dir, dst_dir, sub_dirs)
@task()
def dateflag(dst=os.path.join(DATA_DIR, 'updated_at'), date=None):
''' write date to file '''
......@@ -458,7 +515,7 @@ def dateflag(dst=os.path.join(DATA_DIR, 'updated_at'), date=None):
file.write(date_str)
@task(cleandata, filldb, compressdb, cpdirs, dateflag)
@task(cleandata, filldb, compressdb, cpdirs, cpdirsbga, dateflag)
def builddb():
''' build a new database '''
......
......@@ -47,7 +47,7 @@ def _load(*paths, in_format=None):
def _rating_data(recommender_path=getattr(settings, 'RECOMMENDER_PATH', None), pk_field='bgg_id'):
recommender = load_recommender(recommender_path)
recommender = load_recommender(recommender_path, 'bgg')
if not recommender:
return {}
......
......@@ -141,11 +141,14 @@ def serialize_date(date, tzinfo=None):
@lru_cache(maxsize=8)
def load_recommender(path):
def load_recommender(path, site='bgg'):
''' load recommender from given path '''
if not path:
return None
try:
if site == 'bga':
from ludoj_recommender import BGARecommender
return BGARecommender.load(path=path)
from ludoj_recommender import BGGRecommender
return BGGRecommender.load(path=path)
except Exception:
......
......@@ -280,10 +280,42 @@ class GameViewSet(PermissionsModelViewSet):
return recommender.recommend_similar(games=like, items=games)
@action(detail=False)
def recommend_bga(self, request):
''' recommend games with Board Game Atlas data '''
user = request.query_params.get('user')
path = getattr(settings, 'BGA_RECOMMENDER_PATH', None)
recommender = load_recommender(path, 'bga')
if recommender is None:
return self.list(request)
recommendation = recommender.recommend(
users=(user,),
# similarity_model=take_first(params.get('model')) == 'similarity',
# exclude_known=parse_bool(take_first(params.get('exclude_known'))),
# exclude_clusters=parse_bool(take_first(params.get('exclude_clusters'))),
star_percentiles=getattr(settings, 'STAR_PERCENTILES', None),
)
del path, recommender
page = self.paginate_queryset(recommendation)
return (
self.get_paginated_response(page) if page is not None
else Response(list(recommendation[:10])))
@action(detail=False)
def recommend(self, request):
''' recommend games '''
site = request.query_params.get('site')
if site == 'bga':
return self.recommend_bga(request)
user = request.query_params.get('user')
like = request.query_params.getlist('like')
......@@ -294,7 +326,7 @@ class GameViewSet(PermissionsModelViewSet):
pubsub_push(user)
path = getattr(settings, 'RECOMMENDER_PATH', None)
recommender = load_recommender(path)
recommender = load_recommender(path, 'bgg')
if recommender is None:
return self.list(request)
......
......@@ -155,7 +155,8 @@ REST_PROXY = {
# Custom
RECOMMENDER_PATH = os.path.join(DATA_DIR, 'recommender')
RECOMMENDER_PATH = os.path.join(DATA_DIR, 'recommender_bgg')
BGA_RECOMMENDER_PATH = os.path.join(DATA_DIR, 'recommender_bga')
STAR_PERCENTILES = (.165, .365, .615, .815, .915, .965, .985, .995)
PUBSUB_PUSH_ENABLED = True
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment