From 71c2c52d47546020154aed15bc8950faa4c9797c Mon Sep 17 00:00:00 2001 From: Scritches Date: Thu, 12 Apr 2018 01:11:15 -0400 Subject: [PATCH] Initial rewrite of goodreads module using async/await Covers original functionality plus adds a new ~reading command. There is probably a lot of duplication in the module itself that can be cleaned up with some additional metaprogramming but *eh* I'm tired tonight. --- install | 2 +- modules/goodreads/goodreads.js | 404 ++++++++++++++++++++++----------- modules/goodreads/strings.json | 19 +- modules/goodreads/usage.json | 6 + 4 files changed, 289 insertions(+), 142 deletions(-) create mode 100644 modules/goodreads/usage.json diff --git a/install b/install index 6a32cd6..ef6a939 100755 --- a/install +++ b/install @@ -14,7 +14,7 @@ if [[ $? -gt 0 ]]; then exit 1 fi -npm install googlemaps humanize feedparser node-units tvdb method-override 500px process async wordnik node-uuid underscore request sandbox express moment-timezone moment jade databank databank-redis ent passport passport-local password-hash connect-flash +npm install googlemaps humanize feedparser node-units tvdb method-override 500px process async wordnik node-uuid underscore request request-promise-native sandbox express moment-timezone moment jade databank databank-redis ent passport passport-local password-hash connect-flash cd public/ wget https://github.com/twbs/bootstrap/releases/download/v3.3.2/bootstrap-3.3.2-dist.zip diff --git a/modules/goodreads/goodreads.js b/modules/goodreads/goodreads.js index 800d474..46b2bee 100644 --- a/modules/goodreads/goodreads.js +++ b/modules/goodreads/goodreads.js @@ -1,162 +1,294 @@ /** * Module Name: GoodReads - * Description: Various goodreads. + * Description: Interacts with the GoodReads API to provide book-oriented functionality to dbot */ -var _ = require('underscore')._, - request = require('request'), - async = require('async'), - moment = require('moment'), - parseString = require('xml2js').parseString; - -var goodreads = function(dbot) { - this.ApiRoot = 'https://www.goodreads.com/'; - +const util = require('util'), + _ = require('underscore')._, + rp = require('request-promise-native'), + parseString = util.promisify(require('xml2js').parseString); + +const GoodReads = function(dbot) { + this.apiRoot = 'https://www.goodreads.com'; + this.internalAPI = { - 'getGoodreads': function(server, nick, callback) { - dbot.api.profile.getProfile(server, nick, function(err, user, profile) { - if(user) { - if(profile && _.has(profile.profile, 'goodreads')) { - callback(user, profile.profile.goodreads); - } else { - callback(user, null); - } - } else { - callback(null, null); - } + 'outputError': (evt, e) => { + switch(e) { + case 'goodreads-error': evt.reply('Error talking to GoodReads.'); return; + case 'book-not-found': evt.reply(dbot.t('gr_nobook')); return; + case 'no-description': evt.reply('No description was found for the book you asked for.'); return; + case 'author-not-found': evt.reply(dbot.t('gr_noauthor')); return; + } + + console.log(e); + evt.reply('Something went wrong and I don\'t know what.'); + }, + + 'formatProfile': profile => { + var shelves = {}; + _.each(profile.user_shelves.user_shelf, shelf => { + shelves[shelf.name] = shelf.book_count['_']; }); + profile.user_shelves = shelves; + return profile; } }; - + this.api = { - 'searchBook': function(term, callback) { - request.get({ - 'url': this.ApiRoot + 'search.xml', - 'qs': { - 'q': term, - 'key': this.config.api_key + 'findBook': async term => { + //https://www.goodreads.com/search/index.xml + const body = await rp({ + uri: this.apiRoot + '/search/index.xml', + qs: { + key: this.config.api_key, + q: term.split(' ').join('+') } - }, function(err, response, body) { - if(!_.isUndefined(body) && !err) { - parseString(body, function(err, result) { - // This is why we don't use XML kids - var result = result['GoodreadsResponse'].search[0].results[0]; - if(_.has(result, 'work')) { - callback(null, { - 'id': result.work[0].best_book[0].id[0]['_'], - 'title': result.work[0].best_book[0].title[0], - 'author': result.work[0].best_book[0].author[0].name[0], - 'rating': result.work[0].average_rating[0] - }); - } else { - callback(true, null); - } - }); - } else { - callback(true, null); + }); + + const response = await parseString(body, { explicitArray: false }); + if(!_.has(response, 'GoodreadsResponse')) throw 'goodreads-error'; + + const result = response.GoodreadsResponse.search.results; + if(!result || !_.has(result, 'work')) throw 'book-not-found'; + if(!result.work[0]) throw 'book-not-found'; + + return { + id: result.work[0].best_book.id['_'], + title: result.work[0].best_book.title, + author: result.work[0].best_book.author.name, + rating: result.work[0].average_rating + }; + }, + + 'getSummaryForBook': async id => { + //https://www.goodreads.com/book/show.xml + const body = await rp({ + uri: this.apiRoot + '/book/show.xml', + qs: { + key: this.config.api_key, + id: id } - }.bind(this)); + }); + + const response = await parseString(body, { explicitArray: false }); + if(!_.has(response, 'GoodreadsResponse')) throw 'goodreads-error'; + + const result = response.GoodreadsResponse.book; + if(!result) throw 'book-not-found'; + if(!_.has(result, 'description')) throw 'no-description'; + + return result.description; + }, + + 'findAuthor': async term => { + //https://www.goodreads.com/api/author_url/ + const body = await rp({ + url: this.apiRoot + '/api/author_url/' + term, + qs: { + key: this.config.api_key + } + }); + + const response = await parseString(body, {explicitArray: false }); + if(!_.has(response, 'GoodreadsResponse')) throw 'goodreads-error'; + + const result = response.GoodreadsResponse.author; + if(!result) throw 'author-not-found'; + + return { + id: result['$'].id, + author: result.name + }; + }, + + 'getProfileById': async id => { + //https://www.goodreads.com/user/show.xml + try { + var body = await rp({ + url: this.apiRoot + '/user/show.xml', + qs: { + key: this.config.api_key, + id: id + } + }); + } + catch (e) { + if(e.statusCode && e.statusCode == 404) { + throw 'user-not-found'; + return; + } + + throw e; + } + + const response = await parseString(body, { explicitArray: false }); + if(!_.has(response, 'GoodreadsResponse')) throw 'goodreads-error'; + + const result = response.GoodreadsResponse.user; + if(!result) throw 'user-not-found'; + + return this.internalAPI.formatProfile(result); }, - 'getProfile': function(id, callback) { - request.get({ - 'url': this.ApiRoot + 'user/show.xml', - 'qs': { - 'username': id, - 'key': this.config.api_key + 'getProfileByName': async username => { + //https://www.goodreads.com/user/show.xml + try { + var body = await rp({ + url: this.apiRoot + '/user/show.xml', + qs: { + key: this.config.api_key, + username: username + } + }); + } + catch (e) { + if(e.statusCode && e.statusCode == 404) { + throw 'user-not-found'; + return; } - }, function(err, response, body) { - if(!_.isUndefined(body) && !err) { - parseString(body, function(err, result) { - if(result && _.has(result, 'GoodreadsResponse')) { - var shelves = {}; - result = result['GoodreadsResponse'].user[0].user_shelves[0].user_shelf; - _.each(result, function(shelf) { - shelves[shelf.name[0]] = shelf.book_count[0]['_']; - }); - callback(null, shelves); - } else { - callback(true, null); - } - }); - } else { - callback(true, null); + + throw e; + } + + const response = await parseString(body, { explicitArray: false }); + if(!_.has(response, 'GoodreadsResponse')) throw 'goodreads-error'; + + const result = response.GoodreadsResponse.user; + if(!result) throw 'user-not-found'; + + return this.internalAPI.formatProfile(result); + }, + + 'getShelfForUserId': async (id, shelf) => { + //https://www.goodreads.com/review/list.xml?v=2 + var body = await rp({ + url: this.apiRoot + '/review/list.xml', + qs: { + v: '2', + key: this.config.api_key, + id: id, + shelf: shelf } }); + + const response = await parseString(body, { explicitArray: false }); + if(!_.has(response, 'GoodreadsResponse')) throw 'goodreads-error'; + + let result = response.GoodreadsResponse.reviews.review; + if(!result) return []; + + if(!_.isArray(result)) { + result = [result]; + } + + return _.map(result, r => { + return { + id: r.book.id['_'], + title: r.book.title_without_series + }; + }); } }; - + this.commands = { - '~book': function(event) { - this.api.searchBook(event.input[1], function(err, res) { - if(!err) { - event.reply(dbot.t('gr_book', { - 'author': res.author, - 'title': res.title, - 'rating': res.rating, - 'link': this.ApiRoot + 'book/show/' + res.id - })); - } else { - event.reply(dbot.t('gr_nobook')); - } - }.bind(this)); - }, - - '~books': function(event) { - var user = event.rUser, - gr = event.rProfile.goodreads; - if(event.res[0]) { - user = event.res[0].user; - gr = event.res[0].gr; + '~book' : async evt => { + try { + const book = await this.api.findBook(evt.input[1]); + evt.reply(dbot.t('gr_book', { + author: book.author, + title: book.title, + rating: book.rating, + link: this.apiRoot + '/book/show/' + book.id + })); } - - this.api.getProfile(gr, function(err, profile) { - if(!err) { - event.reply(dbot.t('gr_books', { - 'user': user.currentNick, - 'read': profile.read, - 'currently_reading': profile['currently-reading'] - })); - } else { - event.reply(dbot.t('gr_unknown')); + catch(e) { this.internalAPI.outputError(evt, e); } + }, + + '~booksummary': async evt => { + try { + console.log(evt.input[1]); + const book = await this.api.findBook(evt.input[1]); + const summary = await this.api.getSummaryForBook(book.id); + evt.reply(dbot.t('gr_summary', { + title: book.title, + summary: summary, + link: this.apiRoot + '/book/show/' + book.id + })); + } + catch(e) { this.internalAPI.outputError(evt, e); } + }, + + '~author' : async evt => { + try { + evt.reply(dbot.t('gr_author', await this.api.findAuthor(evt.input[1]))); + } + catch(e) { this.internalAPI.outputError(evt, e); } + }, + + '~reading': async (evt, profile) => { + try { + let books = await this.api.getShelfForUserId(profile.id, 'currently-reading'); + const booksCount = books.length; + if(!booksCount) { + evt.reply(dbot.t('gr_not_reading', { user: evt.rUser.currentNick })); + return; } - }); + + let tooMany = booksCount > 5; + if (tooMany) books = _.sample(books, 5); + + evt.reply(dbot.t('gr_is_reading', { user: evt.rUser.currentNick, count: booksCount })); + _.each(books, b => { + evt.reply(ostr = b.title + ' - https://www.goodreads.com/book/show/' + b.id); + }); + + if (tooMany) { + evt.reply('... And ' + (booksCount - 5) + ' more - https://www.goodreads.com/review/list/' + profile.id + '?shelf=currently-reading'); + } + } + catch(e) { this.internalAPI.outputError(evt, e); } } }; - this.commands['~book'].regex = [/^book ([\d\w\s-]*)/, 2]; - - _.each(this.commands, function(command) { - command.resolver = function(event, callback) { - if(event.rProfile && _.has(event.rProfile, 'goodreads')) { - if(event.params[1]) { - this.internalAPI.getGoodreads(event.server, event.params[1], function(user, gr) { - if(user && gr) { - event.res.push({ - 'user': user, - 'gr': gr - }); - callback(false); - } else { - if(!user) { - event.reply('Unknown user.'); - } else { - event.reply(user.currentNick + ': Set a Goodreads username with "~set goodreads username"'); - } - callback(true); - } - }); - } else { - callback(false); + + this.commands['~book'].regex = [/^book (.*)/, 2]; + this.commands['~booksummary'].regex = [/^booksummary (.*)/, 2]; + this.commands['~author'].regex = [/^author ([\d\w\s-]*)/, 2]; + + this.commands['~reading'].requiresProfile = true; + + _.each(this.commands, ((cmd, cmdName) => { + if(cmd.requiresProfile) { + this.commands[cmdName] = (async evt => { + const grUsername = evt.rProfile.goodreads; + + if(!grUsername) { + evt.reply(evt.rUser.currentNick + ': Set a Goodreads username with "~set goodreads username"'); + return; } - } else { - event.reply(event.user + ': Set a goodreads username with "~set goodreads username"'); - callback(true); - } - }.bind(this); - }, this); + + let grId = evt.rProfile.goodreads_id; + + try { + var profile; + if(grId) { + profile = await this.api.getProfileById(grId); + } else { + profile = await this.api.getProfileByName(grUsername); + grId = profile.id; + dbot.api.profile.setProperty(evt.server, evt.user, 'goodreads_id', grId, function(){}); + } -}; + await cmd(evt, profile); + } + catch(e) { + if(e === 'user-not-found') evt.reply('User not found. Is your GoodReads username set correctly?'); + else this.internalAPI.outputError(evt, e); + } + }).bind(this); + } + }).bind(this)) + +} -exports.fetch = function(dbot) { - return new goodreads(dbot); -}; + +exports.fetch = dbot => new GoodReads(dbot); \ No newline at end of file diff --git a/modules/goodreads/strings.json b/modules/goodreads/strings.json index deff680..1c1c18e 100644 --- a/modules/goodreads/strings.json +++ b/modules/goodreads/strings.json @@ -2,13 +2,22 @@ "gr_book": { "en": "[{title} by {author} - {rating}] - {link}" }, + "gr_summary": { + "en": "[{title}] - {summary} - {link}" + }, + "gr_author": { + "en": "[{author}] - https://www.goodreads.com/author/show/{id}" + }, "gr_nobook": { - "en": "No books found." + "en": "No book by that name was found." }, - "gr_books": { - "en": "{user} has read {read} books and is currently reading {currently_reading} books." + "gr_noauthor": { + "en": "No author by that name was found." }, - "gr_unknown": { - "en": "Unknown Goodreads username!" + "gr_not_reading": { + "en": "{user} is not currently reading any books." + }, + "gr_is_reading": { + "en": "{user} is currently reading the following {count} books:" } } diff --git a/modules/goodreads/usage.json b/modules/goodreads/usage.json new file mode 100644 index 0000000..30c11be --- /dev/null +++ b/modules/goodreads/usage.json @@ -0,0 +1,6 @@ +{ + "~book": "~book [bookname] - returns the title, author, rating, and GoodReads link", + "~booksummary": "~booksummary [bookname] - returns the summary for the requested book", + "~author": "~author [authorname] - returns the GoodReads link for the requested author", + "~reading": "~reading - displays up to 5 of the books you are currently reading" +} \ No newline at end of file