├── .github └── workflows │ ├── node.js.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── README.md ├── lib ├── config.js ├── methods │ ├── answers.js │ ├── questions.js │ ├── search.js │ ├── tags.js │ └── users.js ├── parser.js ├── post.js ├── query.js └── stackexchange.js ├── package-lock.json ├── package.json └── test ├── answers-test.js ├── common.js ├── config-test.js ├── mocha.opts ├── questions-test.js ├── search-test.js ├── tags-test.js └── users-test.js /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [12.x, 14.x, 16.x, 17.x] 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - run: npm ci 28 | - run: npm run build --if-present 29 | - run: npm test 30 | env: 31 | CI: true 32 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | release: 8 | name: Release 9 | runs-on: ubuntu-20.04 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v2 13 | with: 14 | fetch-depth: 0 15 | persist-credentials: false 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: 16 20 | - name: Install dependencies 21 | run: npm ci 22 | - name: Release 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 25 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 26 | run: npx semantic-release 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | node_modules/ 3 | .idea 4 | .vscode 5 | lib-cov 6 | *.seed 7 | *.log 8 | *.csv 9 | *.dat 10 | *.out 11 | *.pid 12 | *.gz 13 | 14 | pids 15 | logs 16 | results 17 | 18 | npm-debug.log 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [2.0.0](https://github.com/Swaagie/stackexchange/compare/v1.3.1...v2.0.0) (2022-01-19) 2 | 3 | 4 | ### chore 5 | 6 | * update node-fetch to 3.1.1 ([6a6be8c](https://github.com/Swaagie/stackexchange/commit/6a6be8c13d72816a9f393efa00f8125a1dc691a1)) 7 | 8 | 9 | ### BREAKING CHANGES 10 | 11 | * Minimum Node.js version supported is now 12.x 12 | 13 | ## [1.3.1](https://github.com/Swaagie/stackexchange/compare/v1.3.0...v1.3.1) (2021-11-10) 14 | 15 | 16 | ### Bug Fixes 17 | 18 | * remove nconf dependency ([7ed4b76](https://github.com/Swaagie/stackexchange/commit/7ed4b7654daa6277b16365afc6c353e8db562dac)) 19 | 20 | ## 1.3.0 21 | 22 | Get comments for questions and answers. (Thanks, c-schuhmann!) 23 | 24 | ## 1.2.5 25 | 26 | Remove deprecated request dependency. No more warnings on install! 27 | 28 | ## 1.2.4 29 | 30 | Correct error message for answers.downvote() that indicated upvote() instead. 31 | 32 | ## 1.2.3 33 | 34 | Fix edge case where callback might get invoked twice. 35 | 36 | ## 1.2.2 37 | 38 | Do not publish tests as part of package. 39 | 40 | ## 1.2.1 41 | 42 | Correct error message for questions.downvote() that indicated upvote() instead. 43 | 44 | ## 1.2.0 45 | 46 | Allow `site` property to be specified in `filter`. (Thanks @karanaggarwal1!) 47 | 48 | ## 1.1.0 49 | 50 | * Add `users.users()`. 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Stackexchange API for Node.js 2 | 3 | Implementation of all stackexchange methods to query questions and awesome 4 | answers. 5 | 6 | Installation 7 | ---- 8 | 9 | ``` 10 | npm install stackexchange --save 11 | ``` 12 | 13 | Usage 14 | ---- 15 | 16 | ```js 17 | var stackexchange = require('stackexchange'); 18 | 19 | var options = { version: 2.2 }; 20 | var context = new stackexchange(options); 21 | 22 | var filter = { 23 | key: 'YOUR_API_KEY', 24 | pagesize: 50, 25 | tagged: 'node.js', 26 | sort: 'activity', 27 | order: 'asc' 28 | }; 29 | 30 | // Get all the questions (http://api.stackexchange.com/docs/questions) 31 | context.questions.questions(filter, function(err, results){ 32 | if (err) throw err; 33 | 34 | console.log(results.items); 35 | console.log(results.has_more); 36 | }); 37 | 38 | // Get results for a different website within the stackexchange network 39 | filter.site = 'softwareengineering'; 40 | context.questions.questions(filter, function(err, results){ 41 | if (err) throw err; 42 | 43 | console.log(results.items); 44 | console.log(results.has_more); 45 | }); 46 | 47 | // Get all users 48 | context.users.users(filter, function(err, results){ 49 | if (err) throw err; 50 | 51 | console.log(results.items); 52 | console.log(results.has_more); 53 | }); 54 | ``` 55 | -------------------------------------------------------------------------------- /lib/config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | // Default configuration. 4 | const config = new Map([ 5 | ['api', 'api.stackexchange.com'], 6 | ['protocol', 'https:'], 7 | ['site', 'stackoverflow'], 8 | ['version', '2.2'] 9 | ]) 10 | 11 | // Expose config 12 | module.exports = config 13 | -------------------------------------------------------------------------------- /lib/methods/answers.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * Required modules. 5 | */ 6 | const query = require('../query') 7 | const post = require('../post') 8 | 9 | /** 10 | * Gets all the answers on the site or returns the answers identified in [ids]. 11 | * 12 | * @param {Object} criteria 13 | * @param {Function} callback return results 14 | * @param {Array} ids collection of IDs 15 | * @api public 16 | */ 17 | function answers (criteria, callback, ids) { 18 | ids = ids || [] 19 | query('answers/' + ids.join(';'), criteria, callback) 20 | } 21 | 22 | /** 23 | * Gets the comments to a set of answers identified in [ids]. 24 | * 25 | * @param {Object} criteria 26 | * @param {Function} callback return results 27 | * @param {Array} ids collection of IDs 28 | * @api public 29 | */ 30 | function comments (criteria, callback, ids) { 31 | if (!ids || !ids.length) return callback(new Error('answers.comments lacks IDs to query')) 32 | query('answers/' + ids.join(';') + '/comments', criteria, callback) 33 | } 34 | 35 | /** 36 | * upvote - Casts an upvote on the selected answer 37 | * 38 | * @param {Object} criteria contains server key and valid access_token 39 | * @param {Integer} id ID of a question 40 | * @param {Function} callback return results 41 | * @param {Boolean} undo Undo the upvote cast 42 | * @api public 43 | */ 44 | function upvote (criteria, id, callback, undo) { 45 | // Key and Access Token are needed in criteria 46 | if (!criteria.key || !criteria.access_token) { 47 | return callback(new Error('answers.upvote lacks key and/or access token as criteria')) 48 | } 49 | undo = undo ? '/undo' : '' 50 | post('answers/' + id.toString() + '/upvote' + undo, criteria, callback) 51 | } 52 | 53 | /** 54 | * downvote - Casts a downvote on the selected answer 55 | * 56 | * @param {Object} criteria contains server key and valid access_token 57 | * @param {Integer} id ID of a question 58 | * @param {Function} callback return results 59 | * @param {Boolean} undo Undo the downvote cast 60 | * @api public 61 | */ 62 | function downvote (criteria, id, callback, undo) { 63 | // Key and Access Token are needed in criteria 64 | if (!criteria.key || !criteria.access_token) { 65 | return callback(new Error('answers.downvote lacks key and/or access token as criteria')) 66 | } 67 | undo = undo ? '/undo' : '' 68 | post('answers/' + id.toString() + '/downvote' + undo, criteria, callback) 69 | } 70 | 71 | // Expose commands. 72 | module.exports.answers = answers 73 | module.exports.comments = comments 74 | module.exports.upvote = upvote 75 | module.exports.downvote = downvote 76 | -------------------------------------------------------------------------------- /lib/methods/questions.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * Required modules. 5 | */ 6 | const query = require('../query') 7 | const post = require('../post') 8 | 9 | /** 10 | * Gets all the questions on the site or returns the questions identified in [ids]. 11 | * 12 | * @param {Object} criteria 13 | * @param {Function} callback return results 14 | * @param {Array} ids collection of IDs 15 | * @api public 16 | */ 17 | function questions (criteria, callback, ids) { 18 | ids = ids || [] 19 | query('questions/' + ids.join(';'), criteria, callback) 20 | } 21 | 22 | /** 23 | * Gets the answers to a set of questions identified in [ids]. 24 | * 25 | * @param {Object} criteria 26 | * @param {Function} callback return results 27 | * @param {Array} ids collection of IDs 28 | * @api public 29 | */ 30 | function answers (criteria, callback, ids) { 31 | if (!ids || !ids.length) return callback(new Error('questions.answers lacks IDs to query')) 32 | query('questions/' + ids.join(';') + '/answers', criteria, callback) 33 | } 34 | 35 | /** 36 | * Gets the comments to a set of questions identified in [ids]. 37 | * 38 | * @param {Object} criteria 39 | * @param {Function} callback return results 40 | * @param {Array} ids collection of IDs 41 | * @api public 42 | */ 43 | function comments (criteria, callback, ids) { 44 | if (!ids || !ids.length) return callback(new Error('questions.comments lacks IDs to query')) 45 | query('questions/' + ids.join(';') + '/comments', criteria, callback) 46 | } 47 | 48 | /** 49 | * upvote - Casts an upvote on the selected question 50 | * 51 | * @param {Object} criteria contains server key and valid access_token 52 | * @param {Integer} id ID of a question 53 | * @param {Function} callback return results 54 | * @param {Boolean} undo Undo the upvote cast 55 | * @api public 56 | */ 57 | function upvote (criteria, id, callback, undo) { 58 | // Key and Access Token are needed in criteria 59 | if (!criteria.key || !criteria.access_token) { 60 | return callback(new Error('questions.upvote lacks key and/or access token as criteria')) 61 | } 62 | undo = undo ? '/undo' : '' 63 | post('questions/' + id.toString() + '/upvote' + undo, criteria, callback) 64 | } 65 | 66 | /** 67 | * downvote - Casts a downvote on the selected question 68 | * 69 | * @param {Object} criteria contains server key and valid access_token 70 | * @param {Integer} id ID of a question 71 | * @param {Function} callback return results 72 | * @param {Boolean} undo Undo the downvote cast 73 | * @api public 74 | */ 75 | function downvote (criteria, id, callback, undo) { 76 | // Key and Access Token are needed in criteria 77 | if (!criteria.key || !criteria.access_token) { 78 | return callback(new Error('questions.downvote lacks key and/or access token as criteria')) 79 | } 80 | undo = undo ? '/undo' : '' 81 | post('questions/' + id.toString() + '/downvote' + undo, criteria, callback) 82 | } 83 | 84 | // Expose commands. 85 | module.exports.questions = questions 86 | module.exports.answers = answers 87 | module.exports.comments = comments 88 | module.exports.upvote = upvote 89 | module.exports.downvote = downvote 90 | -------------------------------------------------------------------------------- /lib/methods/search.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * Required modules. 5 | */ 6 | const query = require('../query') 7 | 8 | /** 9 | * Searches a site for any questions which fit the given criteria. 10 | * 11 | * @param {Object} criteria 12 | * @param {Function} callback return results 13 | * @api public 14 | */ 15 | function search (criteria, callback) { 16 | query('search', criteria, callback) 17 | } 18 | 19 | /** 20 | * Extension of search, allows more criteria. 21 | * 22 | * @param {Object} criteria 23 | * @param {Function} callback return results 24 | * @api public 25 | */ 26 | function advanced (criteria, callback) { 27 | query('search/advanced', criteria, callback) 28 | } 29 | 30 | // Expose commands. 31 | module.exports.search = search 32 | module.exports.advanced = advanced 33 | -------------------------------------------------------------------------------- /lib/methods/tags.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * Required modules. 5 | */ 6 | const query = require('../query') 7 | 8 | function tagsValidator (tags) { 9 | return function () { 10 | if (!tags || !tags.length) { 11 | return 'tags is required' 12 | } 13 | return false 14 | } 15 | } 16 | 17 | const sortPattern1 = /^popular$|^activity$|^name$/ 18 | function sortValidator1 (sort) { 19 | return function () { 20 | if (!sort) { 21 | return 'sort is required' 22 | } 23 | if (!sort.match(sortPattern1)) { 24 | return 'sort is invalid. [popular|activity|name]' 25 | } 26 | return false 27 | } 28 | } 29 | 30 | const sortPattern2 = /^creation$|^applied$|^activity$/ 31 | function sortValidator2 (sort) { 32 | return function () { 33 | if (!sort) { 34 | return 'sort is required' 35 | } 36 | if (!sort.match(sortPattern2)) { 37 | return 'sort is invalid. [creation|applied|activity]' 38 | } 39 | return false 40 | } 41 | } 42 | 43 | const periodPattern = /^all_time$|^month$/ 44 | function periodValidator (period) { 45 | return function () { 46 | if (!period.match(periodPattern)) { 47 | return 'sort is invalid. [all_time|month]' 48 | } 49 | return false 50 | } 51 | } 52 | 53 | function run (validators, tags, criteria, callback) { 54 | const errors = [] 55 | validators.forEach(function (validator) { 56 | const err = validator() 57 | if (err) { 58 | errors.push(err) 59 | } 60 | }) 61 | if (errors.length !== 0) { 62 | process.nextTick(callback, new Error(errors.join(', '))) 63 | return 64 | } 65 | query(tags, criteria, callback) 66 | } 67 | 68 | /** 69 | * Get the tags on the site. 70 | * 71 | * @param {Object} criteria 72 | * @param {Function} callback return results 73 | * @api public 74 | */ 75 | function tags (criteria, callback) { 76 | run([sortValidator1(criteria.sort)], 'tags', criteria, callback) 77 | } 78 | 79 | /** 80 | * Get tags on the site by their names. 81 | * 82 | * @param {Object} criteria 83 | * @param {Function} callback return results 84 | * @param {Array} tags collection of Tag 85 | * @api public 86 | */ 87 | function info (criteria, callback, tags) { 88 | run([tagsValidator(tags), sortValidator1(criteria.sort)], 'tags/' + tags.join(';') + '/info', criteria, callback) 89 | } 90 | 91 | /** 92 | * Get the tags on the site that only moderators can use. 93 | * 94 | * @param {Object} criteria 95 | * @param {Function} callback return results 96 | * @api public 97 | */ 98 | function moderatorOnly (criteria, callback) { 99 | run([sortValidator1(criteria.sort)], 'tags/moderator-only', criteria, callback) 100 | } 101 | 102 | /** 103 | * Get the tags on the site that fulfill required tag constraints. 104 | * 105 | * @param {Object} criteria 106 | * @param {Function} callback return results 107 | * @api public 108 | */ 109 | function required (criteria, callback) { 110 | run([sortValidator1(criteria.sort)], 'tags/required', criteria, callback) 111 | } 112 | 113 | /** 114 | * Get all the tag synonyms on the site. 115 | * 116 | * @param {Object} criteria 117 | * @param {Function} callback return results 118 | * @api public 119 | */ 120 | function synonyms (criteria, callback) { 121 | run([sortValidator2(criteria.sort)], 'tags/synonyms', criteria, callback) 122 | } 123 | 124 | /** 125 | * Get frequently asked questions in a set of tags. 126 | * 127 | * @param {Object} criteria 128 | * @param {Function} callback return results 129 | * @param {Array} tags collection of Tag 130 | * @api public 131 | */ 132 | function faq (criteria, callback, tags) { 133 | run([tagsValidator(tags)], 'tags/' + tags.join(';') + '/faq', criteria, callback) 134 | } 135 | 136 | /** 137 | * Get related tags, based on common tag pairings. 138 | * 139 | * @param {Object} criteria 140 | * @param {Function} callback return results 141 | * @param {Array} tags collection of Tag 142 | * @api public 143 | */ 144 | function related (criteria, callback, tags) { 145 | run([tagsValidator(tags)], 'tags/' + tags.join(';') + '/related', criteria, callback) 146 | } 147 | 148 | /** 149 | * Get the synonyms for a specific set of tags. 150 | * 151 | * @param {Object} criteria 152 | * @param {Function} callback return results 153 | * @param {Array} tags collection of Tag 154 | * @api public 155 | */ 156 | function tagsSynonyms (criteria, callback, tags) { 157 | run([tagsValidator(tags), sortValidator2(criteria.sort)], 'tags/' + tags.join(';') + '/synonyms', criteria, callback) 158 | } 159 | 160 | /** 161 | * Get the top answer posters in a specific tag, either in the last month or for all time. 162 | * 163 | * @param {Object} criteria 164 | * @param {Function} callback return results 165 | * @param {Array} tags collection of Tag 166 | * @param {String} all_time or month 167 | * @api public 168 | */ 169 | function topAnswerers (criteria, callback, tags, period) { 170 | period = period || 'all_time' 171 | run([tagsValidator(tags), periodValidator(period)], 'tags/' + tags.join(';') + '/top-answerers/' + period, 172 | criteria, callback) 173 | } 174 | 175 | /** 176 | * Get the top question askers in a specific tag, either in the last month or for all time. 177 | * 178 | * @param {Object} criteria 179 | * @param {Function} callback return results 180 | * @param {Array} tags collection of Tag 181 | * @param {String} all_time or month 182 | * @api public 183 | */ 184 | function topAskers (criteria, callback, tags, period) { 185 | period = period || 'all_time' 186 | run([tagsValidator(tags), periodValidator(period)], 'tags/' + tags.join(';') + '/top-askers/' + period, 187 | criteria, callback) 188 | } 189 | 190 | /** 191 | * Get the wiki entries for a set of tags. 192 | * 193 | * @param {Object} criteria 194 | * @param {Function} callback return results 195 | * @param {Array} tags collection of Tag 196 | * @api public 197 | */ 198 | function wiki (criteria, callback, tags) { 199 | run([tagsValidator(tags)], 'tags/' + tags.join(';') + '/wikis', criteria, callback) 200 | } 201 | 202 | // Expose commands. 203 | module.exports.tags = tags 204 | module.exports.info = info 205 | module.exports.moderatorOnly = moderatorOnly 206 | module.exports.required = required 207 | module.exports.synonyms = synonyms 208 | module.exports.faq = faq 209 | module.exports.related = related 210 | module.exports.tagsSynonyms = tagsSynonyms 211 | module.exports.topAnswerers = topAnswerers 212 | module.exports.topAskers = topAskers 213 | module.exports.wiki = wiki 214 | -------------------------------------------------------------------------------- /lib/methods/users.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * Required modules. 5 | */ 6 | const query = require('../query') 7 | 8 | /** 9 | * Gets all the users on the site or returns the users identified in [ids]. 10 | * 11 | * @param {Object} criteria 12 | * @param {Array} ids collection of IDs 13 | * @param {Function} callback return results 14 | * @api public 15 | */ 16 | function users (criteria, ids, callback) { 17 | if (typeof ids === 'function') { 18 | callback = ids 19 | ids = [] 20 | } 21 | query('users/' + ids.join(';'), criteria, callback) 22 | } 23 | 24 | /** 25 | * Gets the answers to a set of users identified in [ids]. 26 | * 27 | * @param {Object} criteria 28 | * @param {Array} ids collection of IDs 29 | * @param {Function} callback return results 30 | * @api public 31 | */ 32 | function answers (criteria, ids, callback) { 33 | if (!ids || !ids.length) return callback(new Error('users.answers lacks IDs to query')) 34 | query('users/' + ids.join(';') + '/answers', criteria, callback) 35 | } 36 | 37 | // Expose commands. 38 | module.exports.users = users 39 | module.exports.answers = answers 40 | -------------------------------------------------------------------------------- /lib/parser.js: -------------------------------------------------------------------------------- 1 | const zlib = require('zlib') 2 | 3 | /** 4 | * Parse the buffer. StackExchange promises to always deliver zipped content. 5 | * 6 | * @param {Buffer} buffer response content 7 | * @param {Function} callback return results 8 | * @api private 9 | */ 10 | function parseBody (buffer, callback) { 11 | zlib.unzip(buffer, function Unzipped (error, body) { 12 | if (error) { 13 | return callback(error) 14 | } 15 | let jsonBody 16 | try { 17 | jsonBody = JSON.parse(body.toString()) 18 | } catch (error) { 19 | return callback(error) 20 | } 21 | callback(undefined, jsonBody) 22 | }) 23 | } 24 | 25 | // Export functions 26 | module.exports.parseBody = parseBody 27 | -------------------------------------------------------------------------------- /lib/post.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const config = require('./config') 4 | const parser = require('./parser') 5 | const url = require('url') 6 | 7 | const fetch = (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args)) 8 | 9 | /** 10 | * Post a query with supplied data. 11 | * 12 | * @param {String} destination query method 13 | * @param {Object} data parameters to send as a POST form 14 | * @param {Function} callback return results 15 | * @api private 16 | */ 17 | module.exports = function post (destination, data, callback) { 18 | if (!callback) throw new Error('No callback supplied for: ' + destination) 19 | 20 | data.site = config.get('site') 21 | const endpoint = `${config.get('protocol')}//${config.get('api')}/${config.get('version')}/${destination}` 22 | 23 | const params = new url.URLSearchParams() 24 | for (const key in data) { 25 | params.append(key, data[key]) 26 | } 27 | 28 | fetch(endpoint, { method: 'POST', body: params }) 29 | .then((res) => res.buffer()) 30 | .then((buffer) => parser.parseBody(buffer, callback)) 31 | .catch(callback) 32 | } 33 | -------------------------------------------------------------------------------- /lib/query.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const config = require('./config') 4 | const url = require('url') 5 | 6 | const fetch = (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args)) 7 | 8 | /** 9 | * Execute a query after checkign if criteria are available. 10 | * 11 | * @param {String} destination query method 12 | * @param {Object} criteria parameters to query against 13 | * @param {Function} callback return results 14 | * @api private 15 | */ 16 | module.exports = function query (destination, criteria, callback) { 17 | if (!callback) throw new Error('No callback supplied for: ' + destination) 18 | 19 | // Query against the passed site parameter or the predefined website and construct the endpoint. 20 | criteria.site = criteria.site || config.get('site') 21 | const endpoint = url.format({ 22 | protocol: config.get('protocol'), 23 | host: config.get('api'), 24 | pathname: '/' + config.get('version') + '/' + destination, 25 | query: criteria 26 | }); 27 | 28 | // Execute the request on proper response call callback. 29 | (async () => { 30 | let body 31 | try { 32 | const res = await fetch(endpoint) 33 | body = await res.json() 34 | } catch (error) { 35 | return callback(error) 36 | } 37 | return callback(undefined, body) 38 | })() 39 | } 40 | -------------------------------------------------------------------------------- /lib/stackexchange.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const config = require('./config') 4 | const search = require('./methods/search') 5 | const questions = require('./methods/questions') 6 | const answers = require('./methods/answers') 7 | const users = require('./methods/users') 8 | const tags = require('./methods/tags') 9 | 10 | /** 11 | * Initialize StackExchange API. 12 | * 13 | * @Constructor 14 | * @param {Object} options 15 | * @api public 16 | */ 17 | module.exports = function StackExchange (options) { 18 | // Mitigate options to config. 19 | this.config = config 20 | Object.keys(options || {}).forEach(function setConfig (key) { 21 | config.set(key, options[key]) 22 | }) 23 | 24 | // Expose methods. 25 | this.search = search 26 | this.questions = questions 27 | this.answers = answers 28 | this.users = users 29 | this.tags = tags 30 | } 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stackexchange", 3 | "version": "2.0.0", 4 | "description": "Node.js implementation of the stackexchange/stackoverflow API", 5 | "main": "./lib/stackexchange", 6 | "files": [ 7 | "lib" 8 | ], 9 | "scripts": { 10 | "test": "standard lib && NODE_ENV=test c8 ./node_modules/.bin/mocha $(find test -name '*.test.js')" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git@github.com:Swaagie/stackexchange.git" 15 | }, 16 | "dependencies": { 17 | "node-fetch": "^3.1.1" 18 | }, 19 | "devDependencies": { 20 | "@semantic-release/changelog": "^6.0.1", 21 | "@semantic-release/git": "^10.0.1", 22 | "c8": "^7.11.0", 23 | "chai": "^4.3.4", 24 | "mocha": "^9.1.4", 25 | "nock": "^13.2.2", 26 | "semantic-release": "^19.0.2", 27 | "standard": "^16.0.4" 28 | }, 29 | "keywords": [ 30 | "stackoverflow", 31 | "stackexchange", 32 | "api", 33 | "questions", 34 | "answers" 35 | ], 36 | "author": "Martijn Swaagman", 37 | "license": "MIT", 38 | "release": { 39 | "plugins": [ 40 | "@semantic-release/commit-analyzer", 41 | "@semantic-release/release-notes-generator", 42 | [ 43 | "@semantic-release/changelog", 44 | { 45 | "changelogFile": "CHANGELOG.md" 46 | } 47 | ], 48 | "@semantic-release/npm", 49 | [ 50 | "@semantic-release/git", 51 | { 52 | "assets": [ 53 | "CHANGELOG.md", 54 | "package.json" 55 | ], 56 | "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" 57 | } 58 | ] 59 | ] 60 | }, 61 | "readmeFilename": "README.md" 62 | } 63 | -------------------------------------------------------------------------------- /test/answers-test.js: -------------------------------------------------------------------------------- 1 | /* tags expect */ 2 | 3 | const { expect } = require('chai') 4 | const nock = require('nock') 5 | const nockScope = nock('https://api.stackexchange.com', { allowUnmocked: true }) 6 | 7 | const stackexchange = require('../lib/stackexchange') 8 | const zlib = require('zlib') 9 | 10 | const answerFixture = { 11 | owner: { 12 | reputation: 9001, 13 | user_id: 1, 14 | user_type: 'registered', 15 | accept_rate: 55, 16 | profile_image: 'https://www.gravatar.com/avatar/a007be5a61f6aa8f3e85ae2fc18dd66e?d=identicon&r=PG', 17 | display_name: 'Example User', 18 | link: 'http://example.stackexchange.com/users/1/example-user' 19 | }, 20 | down_vote_count: 2, 21 | up_vote_count: 3, 22 | is_accepted: false, 23 | score: 1, 24 | last_activity_date: 1615257075, 25 | last_edit_date: 1615282275, 26 | creation_date: 1615213875, 27 | answer_id: 5678, 28 | question_id: 1234, 29 | link: 'http://example.stackexchange.com/questions/1234/an-example-post-title/5678#5678', 30 | title: 'An example post title', 31 | body: 'An example post body' 32 | } 33 | 34 | describe('Answers', function () { 35 | 'use strict' 36 | 37 | let options, context, filter 38 | 39 | beforeEach(function () { 40 | options = { version: 2.2 } 41 | context = new stackexchange(options) 42 | filter = { 43 | pagesize: 10, 44 | sort: 'activity', 45 | order: 'asc' 46 | } 47 | }) 48 | 49 | it('throws if no callback', function () { 50 | expect(() => { context.answers.answers(filter) }).to.throw() 51 | expect(() => { context.answers.comments(filter) }).to.throw() 52 | }) 53 | 54 | it('gets all answers', function (done) { 55 | nockScope.get('/2.2/answers/?pagesize=10&sort=activity&order=asc&site=stackoverflow') 56 | .reply(200, {}) 57 | 58 | context.answers.answers(filter, function (err, results) { 59 | if (err) throw err 60 | done() 61 | }) 62 | }) 63 | 64 | it('gets specified answers', function (done) { 65 | context.answers.answers(filter, function (err, results) { 66 | if (err) throw err 67 | 68 | expect(results.items).to.have.length(1) 69 | expect(results.has_more).to.be.false 70 | done() 71 | }, [66539406]) 72 | }) 73 | 74 | it('get comments', function (done) { 75 | filter.sort = undefined 76 | const answerIds = [11227902, 11227877, 11237235, 12853037, 14889969] 77 | const callback = (err, results) => { 78 | if (err) throw err 79 | 80 | expect(results.items).to.have.length(10) 81 | expect(results.has_more).to.be.true 82 | expect(results.items.every((val) => val.hasOwnProperty('comment_id'))).to.be.true 83 | 84 | done() 85 | } 86 | context.answers.comments(filter, callback, answerIds) 87 | }) 88 | 89 | it('use a different site via filter', function (done) { 90 | nockScope.get('/2.2/answers/?pagesize=10&sort=activity&order=asc&site=softwareengineering') 91 | .reply(200, {}) 92 | filter.site = 'softwareengineering' 93 | context.answers.answers(filter, function (err, results) { 94 | if (err) throw err 95 | done() 96 | }) 97 | }) 98 | 99 | it('upvote/downvote should throw an error without key or access_token', function () { 100 | function check (fn) { 101 | const cases = [{}, { key: 'fhqwhgads' }, { access_token: 'fhqwhgads' }] 102 | cases.forEach((filter) => { 103 | context.answers[fn](filter, '51812', (err) => { 104 | expect(err.message).to.equal(`answers.${fn} lacks key and/or access token as criteria`) 105 | }) 106 | }) 107 | } 108 | 109 | check('upvote') 110 | check('downvote') 111 | }) 112 | 113 | { 114 | const filter = { key: 'fhqwhgads', access_token: 'fhqwhgads' } 115 | { 116 | it('upvote should post to expected endpoints', function (done) { 117 | nockScope.post('/2.2/answers/42/upvote', filter) 118 | .reply(200, zlib.deflateSync(Buffer.from(JSON.stringify(answerFixture)))) 119 | 120 | context.answers.upvote(filter, '42', (err, question) => { 121 | expect(err).to.be.undefined 122 | expect(question).to.deep.equal(answerFixture) 123 | done() 124 | }) 125 | }) 126 | 127 | it('downvote should post to expected endpoints', function (done) { 128 | nockScope.post('/2.2/answers/42/downvote', filter) 129 | .reply(200, zlib.deflateSync(Buffer.from(JSON.stringify(answerFixture)))) 130 | 131 | context.answers.downvote(filter, '42', (err, question) => { 132 | expect(err).to.be.undefined 133 | expect(question).to.deep.equal(answerFixture) 134 | done() 135 | }) 136 | }) 137 | 138 | it('upvote with undo should post to expected endpoints', function (done) { 139 | nockScope.post('/2.2/answers/42/upvote/undo', filter) 140 | .reply(200, zlib.deflateSync(Buffer.from(JSON.stringify(answerFixture)))) 141 | 142 | context.answers.upvote(filter, '42', (err, question) => { 143 | expect(err).to.be.undefined 144 | expect(question).to.deep.equal(answerFixture) 145 | done() 146 | }, true) 147 | }) 148 | 149 | it('downvote with undo should post to expected endpoints', function (done) { 150 | nockScope.post('/2.2/answers/42/downvote/undo', filter) 151 | .reply(200, zlib.deflateSync(Buffer.from(JSON.stringify(answerFixture)))) 152 | 153 | context.answers.downvote(filter, '42', (err, question) => { 154 | expect(err).to.be.undefined 155 | expect(question).to.deep.equal(answerFixture) 156 | done() 157 | }, true) 158 | }) 159 | } 160 | } 161 | }) 162 | -------------------------------------------------------------------------------- /test/common.js: -------------------------------------------------------------------------------- 1 | global.chai = require('chai') 2 | global.expect = global.chai.expect 3 | 4 | global.chai.config.includeStack = true 5 | -------------------------------------------------------------------------------- /test/config-test.js: -------------------------------------------------------------------------------- 1 | /* global expect */ 2 | const config = require('../lib/config') 3 | 4 | describe('Config', function () { 5 | 'use strict' 6 | 7 | it('has API key', function () { 8 | expect(config.get('api')).to.equal('api.stackexchange.com') 9 | }) 10 | 11 | it('has API endpoint', function () { 12 | expect(config.get('site')).to.equal('stackoverflow') 13 | }) 14 | 15 | it('has API version', function () { 16 | const version = config.get('version') 17 | 18 | expect(version).to.equal(2.2) 19 | expect(version).to.be.a('Number') 20 | }) 21 | 22 | it('has default protocol', function () { 23 | expect(config.get('protocol')).to.equal('https:') 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --require ./test/common 2 | --reporter spec 3 | -------------------------------------------------------------------------------- /test/questions-test.js: -------------------------------------------------------------------------------- 1 | /* tags expect */ 2 | 3 | const { expect } = require('chai') 4 | const nock = require('nock') 5 | const nockScope = nock('https://api.stackexchange.com', { allowUnmocked: true }) 6 | 7 | const stackexchange = require('../lib/stackexchange') 8 | const zlib = require('zlib') 9 | 10 | const questionFixture = { 11 | tags: [ 12 | 'windows', 13 | 'c#', 14 | '.net' 15 | ], 16 | owner: { 17 | reputation: 9001, 18 | user_id: 1, 19 | user_type: 'registered', 20 | accept_rate: 55, 21 | profile_image: 'https://www.gravatar.com/avatar/a007be5a61f6aa8f3e85ae2fc18dd66e?d=identicon&r=PG', 22 | display_name: 'Example User', 23 | link: 'https://example.stackexchange.com/users/1/example-user' 24 | }, 25 | is_answered: false, 26 | view_count: 31415, 27 | favorite_count: 1, 28 | down_vote_count: 2, 29 | up_vote_count: 3, 30 | answer_count: 0, 31 | score: 1, 32 | last_activity_date: 1615207909, 33 | creation_date: 1615164709, 34 | last_edit_date: 1615233109, 35 | question_id: 1234, 36 | link: 'https://example.stackexchange.com/questions/1234/an-example-post-title', 37 | title: 'An example post title', 38 | body: 'An example post body' 39 | } 40 | 41 | describe('Questions', function () { 42 | 'use strict' 43 | 44 | let options, context, filter 45 | 46 | beforeEach(function () { 47 | options = { version: 2.2 } 48 | context = new stackexchange(options) 49 | filter = { 50 | pagesize: 10, 51 | sort: 'activity', 52 | order: 'asc' 53 | } 54 | }) 55 | 56 | it('throws if no callback', function () { 57 | expect(() => { context.questions.questions(filter) }).to.throw() 58 | expect(() => { context.questions.answers(filter) }).to.throw() 59 | expect(() => { context.questions.comments(filter) }).to.throw() 60 | }) 61 | 62 | it('get questions and answers', function (done) { 63 | nockScope.get('/2.2/questions/?pagesize=10&sort=activity&order=asc&site=stackoverflow') 64 | .reply(200, { items: [{ tags: ['database', 'architecture'], owner: { reputation: 114592, user_id: 1196, user_type: 'registered', accept_rate: 37, profile_image: 'https://i.stack.imgur.com/iQcva.jpg?s=128&g=1', display_name: 'aku', link: 'https://stackoverflow.com/users/1196/aku' }, is_answered: true, view_count: 1153, closed_date: 1543160438, answer_count: 4, score: 7, last_activity_date: 1220367388, creation_date: 1220364992, last_edit_date: 1495535570, question_id: 39628, link: 'https://stackoverflow.com/questions/39628/keeping-validation-logic-in-sync-between-server-and-client-sides', closed_reason: 'Opinion-based', title: 'Keeping validation logic in sync between server and client sides' }, { tags: ['windows-vista', 'virtual-pc'], owner: { reputation: 24662, user_id: 2429, user_type: 'registered', accept_rate: 82, profile_image: 'https://www.gravatar.com/avatar/9d268448378b2d9864c976047d93d5a9?s=128&d=identicon&r=PG', display_name: 'Seb Nilsson', link: 'https://stackoverflow.com/users/2429/seb-nilsson' }, is_answered: true, view_count: 1533, accepted_answer_id: 40819, answer_count: 1, score: 4, last_activity_date: 1220394139, creation_date: 1220357523, last_edit_date: 1220362348, question_id: 39357, content_license: 'CC BY-SA 2.5', link: 'https://stackoverflow.com/questions/39357/windows-vista-virtual-pc-image-for-visual-studio-development-minimized', title: 'Windows Vista Virtual PC-image for Visual Studio-development minimized' }, { tags: ['sql-server', 'port', 'msde'], owner: { reputation: 6235, user_id: 3475, user_type: 'registered', accept_rate: 91, profile_image: 'https://www.gravatar.com/avatar/592a0a2bca674feca1bb6ca1d05665e4?s=128&d=identicon&r=PG', display_name: 'Scott Lawrence', link: 'https://stackoverflow.com/users/3475/scott-lawrence' }, is_answered: true, view_count: 12124, accepted_answer_id: 42196, answer_count: 4, score: 13, last_activity_date: 1220463905, creation_date: 1220462754, question_id: 42146, content_license: 'CC BY-SA 2.5', link: 'https://stackoverflow.com/questions/42146/what-are-the-best-ways-to-determine-what-port-an-application-is-using', title: 'What are the best ways to determine what port an application is using?' }, { tags: ['mobile', 'hardware', 'pocketpc'], owner: { reputation: 10215, user_id: 2213, user_type: 'registered', accept_rate: 92, profile_image: 'https://www.gravatar.com/avatar/78897c96c45c02fa88b191c854fa83a8?s=128&d=identicon&r=PG', display_name: 'Ian Patrick Hughes', link: 'https://stackoverflow.com/users/2213/ian-patrick-hughes' }, is_answered: true, view_count: 157, accepted_answer_id: 42339, answer_count: 1, score: 3, last_activity_date: 1220470238, creation_date: 1220468401, question_id: 42312, content_license: 'CC BY-SA 2.5', link: 'https://stackoverflow.com/questions/42312/are-there-adapters-for-cf-type-ii-to-microsd', title: 'Are There Adapters for CF Type II to MicroSD?' }, { tags: ['.net', 'windows', 'networking', 'remoting'], owner: { reputation: 12886, user_id: 3776, user_type: 'registered', accept_rate: 100, profile_image: 'https://www.gravatar.com/avatar/c8675f33db6abf21dfda734e66d53f09?s=128&d=identicon&r=PG', display_name: 'McKenzieG1', link: 'https://stackoverflow.com/users/3776/mckenzieg1' }, is_answered: true, view_count: 687, accepted_answer_id: 42474, answer_count: 2, score: 7, last_activity_date: 1220489665, creation_date: 1220472748, question_id: 42468, content_license: 'CC BY-SA 2.5', link: 'https://stackoverflow.com/questions/42468/how-do-i-measure-bytes-in-out-of-an-ip-port-used-for-net-remoting', title: 'How do I measure bytes in/out of an IP port used for .NET remoting?' }, { tags: ['c#', 'polymorphism'], owner: { reputation: 910, user_id: 3602, user_type: 'registered', profile_image: 'https://www.gravatar.com/avatar/a57e127cbcfdd4cce20e44d14ea033f5?s=128&d=identicon&r=PG', display_name: 'TrolleFar', link: 'https://stackoverflow.com/users/3602/trollefar' }, is_answered: true, view_count: 7022, accepted_answer_id: 43516, answer_count: 6, score: 18, last_activity_date: 1220529061, creation_date: 1220527654, question_id: 43511, content_license: 'CC BY-SA 2.5', link: 'https://stackoverflow.com/questions/43511/can-i-prevent-an-inherited-virtual-method-from-being-overridden-in-subclasses', title: 'Can I prevent an inherited virtual method from being overridden in subclasses?' }, { tags: ['.net', 'configuration'], owner: { reputation: 32690, user_id: 2361, user_type: 'registered', accept_rate: 85, profile_image: 'https://i.stack.imgur.com/GPBvD.jpg?s=128&g=1', display_name: 'Jakub Šturc', link: 'https://stackoverflow.com/users/2361/jakub-%c5%a0turc' }, is_answered: true, view_count: 2179, accepted_answer_id: 43633, answer_count: 1, score: 4, last_activity_date: 1220531610, creation_date: 1220530453, last_edit_date: 1220530974, question_id: 43591, content_license: 'CC BY-SA 2.5', link: 'https://stackoverflow.com/questions/43591/override-webclientprotocol-timeout-via-web-config', title: 'Override WebClientProtocol.Timeout via web.config' }, { tags: ['web-services', 'web', 'web-applications'], owner: { user_type: 'does_not_exist', display_name: 'Elijah Manor' }, is_answered: true, view_count: 184, accepted_answer_id: 43407, answer_count: 2, score: 4, last_activity_date: 1220531919, creation_date: 1220466183, question_id: 42262, content_license: 'CC BY-SA 2.5', link: 'https://stackoverflow.com/questions/42262/twitching-consumption-of-web-services-from-web-site-to-web-application', title: 'Twitching Consumption of Web Services from Web Site to Web Application' }, { tags: ['asp.net-mvc'], owner: { reputation: 2091, user_id: 4204, user_type: 'registered', accept_rate: 88, profile_image: 'https://www.gravatar.com/avatar/61273477b46f3d7e57c6cbb51d38301e?s=128&d=identicon&r=PG', display_name: 'Mihai Lazar', link: 'https://stackoverflow.com/users/4204/mihai-lazar' }, is_answered: true, view_count: 1679, accepted_answer_id: 43363, answer_count: 2, score: 9, last_activity_date: 1220532625, creation_date: 1220508765, last_edit_date: 1220528186, question_id: 43243, content_license: 'CC BY-SA 2.5', link: 'https://stackoverflow.com/questions/43243/how-does-web-routing-work', title: 'How does Web Routing Work?' }, { tags: ['.net', 'wpf', 'performance'], owner: { reputation: 11621, user_id: 93, user_type: 'registered', accept_rate: 95, profile_image: 'https://www.gravatar.com/avatar/ce74962e5a92bca7a49c6595668d4cd4?s=128&d=identicon&r=PG', display_name: 'MojoFilter', link: 'https://stackoverflow.com/users/93/mojofilter' }, is_answered: true, view_count: 487, accepted_answer_id: 43771, answer_count: 1, score: 5, last_activity_date: 1220535302, creation_date: 1220535156, last_edit_date: 1220535302, question_id: 43768, content_license: 'CC BY-SA 2.5', link: 'https://stackoverflow.com/questions/43768/wpf-control-performance', title: 'WPF control performance' }], has_more: true, quota_max: 300, quota_remaining: 296 }) 65 | context.questions.questions(filter, function (err, results) { 66 | if (err) throw err 67 | 68 | expect(results.items).to.have.length(10) 69 | expect(results.has_more).to.be.true 70 | expect(results.items.every((val) => val.link.startsWith('https://stackoverflow.com/questions/'))).to.be.true 71 | 72 | const questionIds = results.items.map((item) => item.question_id) 73 | const callback = (err, results) => { 74 | if (err) throw err 75 | 76 | expect(results.items).to.have.length(10) 77 | expect(results.has_more).to.be.true 78 | expect(results.items.every((val) => val.hasOwnProperty('answer_id'))).to.be.true 79 | 80 | done() 81 | } 82 | nockScope.get('/2.2/questions/39628;39357;42146;42312;42468;43511;43591;42262;43243;43768/answers?pagesize=10&sort=activity&order=asc&site=stackoverflow') 83 | .reply(200, { items: [{ owner: { reputation: 35320, user_id: 1219, user_type: 'registered', accept_rate: 60, profile_image: 'https://www.gravatar.com/avatar/62cc585b9fd3ee7182dadbd09a7f4b47?s=128&d=identicon&r=PG', display_name: 'Eric Z Beard', link: 'https://stackoverflow.com/users/1219/eric-z-beard' }, is_accepted: false, score: 4, last_activity_date: 1220365473, creation_date: 1220365473, answer_id: 39654, question_id: 39628, content_license: 'CC BY-SA 2.5' }, { owner: { reputation: 31, user_id: 2870, user_type: 'registered', display_name: 'user2870', link: 'https://stackoverflow.com/users/2870/user2870' }, is_accepted: false, score: 2, last_activity_date: 1220366326, creation_date: 1220366326, answer_id: 39683, question_id: 39628, content_license: 'CC BY-SA 2.5' }, { owner: { reputation: 13087, user_id: 4213, user_type: 'registered', accept_rate: 80, profile_image: 'https://i.stack.imgur.com/oWF0Y.jpg?s=128&g=1', display_name: 'Marcio Aguiar', link: 'https://stackoverflow.com/users/4213/marcio-aguiar' }, is_accepted: false, score: 2, last_activity_date: 1220366935, creation_date: 1220366935, answer_id: 39706, question_id: 39628, content_license: 'CC BY-SA 2.5' }, { owner: { reputation: 358817, user_id: 3043, user_type: 'registered', accept_rate: 70, profile_image: 'https://www.gravatar.com/avatar/61d2a0f034915fa9d2acd6f6b145bba8?s=128&d=identicon&r=PG', display_name: 'Joel Coehoorn', link: 'https://stackoverflow.com/users/3043/joel-coehoorn' }, is_accepted: false, score: 1, last_activity_date: 1220367083, creation_date: 1220367083, answer_id: 39708, question_id: 39628, content_license: 'CC BY-SA 2.5' }, { owner: { reputation: 1065, user_id: 291, user_type: 'registered', profile_image: 'https://www.gravatar.com/avatar/b0b1ce3a4e0a77abd157ec0309b72922?s=128&d=identicon&r=PG', display_name: 'The How-To Geek', link: 'https://stackoverflow.com/users/291/the-how-to-geek' }, is_accepted: true, score: 3, last_activity_date: 1220394139, creation_date: 1220394139, answer_id: 40819, question_id: 39357, content_license: 'CC BY-SA 2.5' }, { owner: { reputation: 310953, user_id: 3153, user_type: 'registered', accept_rate: 98, profile_image: 'https://www.gravatar.com/avatar/47d8644c0ad8d89635fca422dd6d3ab5?s=128&d=identicon&r=PG', display_name: 'Brian R. Bondy', link: 'https://stackoverflow.com/users/3153/brian-r-bondy' }, is_accepted: false, score: 2, last_activity_date: 1220462830, creation_date: 1220462830, answer_id: 42147, question_id: 42146, content_license: 'CC BY-SA 2.5' }, { owner: { reputation: 366646, user_id: 1288, user_type: 'registered', accept_rate: 93, profile_image: 'https://www.gravatar.com/avatar/fc763c6ff6c160ddad05741e87e517b6?s=128&d=identicon&r=PG', display_name: 'Bill the Lizard', link: 'https://stackoverflow.com/users/1288/bill-the-lizard' }, is_accepted: false, score: 12, last_activity_date: 1220462956, creation_date: 1220462956, answer_id: 42152, question_id: 42146, content_license: 'CC BY-SA 2.5' }, { owner: { reputation: 2132, user_id: 3657, user_type: 'registered', accept_rate: 100, profile_image: 'https://www.gravatar.com/avatar/33c9f96632ecdb70c138e1cd637dbdbc?s=128&d=identicon&r=PG', display_name: 'Jeremy', link: 'https://stackoverflow.com/users/3657/jeremy' }, is_accepted: false, score: 6, last_activity_date: 1220463530, creation_date: 1220463530, answer_id: 42174, question_id: 42146, content_license: 'CC BY-SA 2.5' }, { owner: { reputation: 4109, user_id: 2885, user_type: 'registered', accept_rate: 75, profile_image: 'https://www.gravatar.com/avatar/79e329fbc6449ee6227a12a56a27d646?s=128&d=identicon&r=PG', display_name: 'Rydell', link: 'https://stackoverflow.com/users/2885/rydell' }, is_accepted: true, score: 8, last_activity_date: 1220463905, creation_date: 1220463905, answer_id: 42196, question_id: 42146, content_license: 'CC BY-SA 2.5' }, { owner: { reputation: 28973, user_id: 194, user_type: 'registered', accept_rate: 63, profile_image: 'https://www.gravatar.com/avatar/b336fc4bc06c327650d1dff9938e7a00?s=128&d=identicon&r=PG', display_name: 'Adam Haile', link: 'https://stackoverflow.com/users/194/adam-haile' }, is_accepted: true, score: 1, last_activity_date: 1220469580, creation_date: 1220469580, answer_id: 42339, question_id: 42312, content_license: 'CC BY-SA 2.5' }], has_more: true, quota_max: 300, quota_remaining: 295 }) 84 | 85 | context.questions.answers(filter, callback, questionIds) 86 | }) 87 | }) 88 | 89 | it('get comments', function (done) { 90 | filter.sort = undefined 91 | const questionIds = [11227809, 927358, 2003505, 292357, 231767] 92 | const callback = (err, results) => { 93 | if (err) throw err 94 | 95 | expect(results.items).to.have.length(10) 96 | expect(results.has_more).to.be.true 97 | expect(results.items.every((val) => val.hasOwnProperty('comment_id'))).to.be.true 98 | 99 | done() 100 | } 101 | context.questions.comments(filter, callback, questionIds) 102 | }) 103 | 104 | it('use a different site via filter', function (done) { 105 | nockScope.get('/2.2/questions/?pagesize=10&sort=activity&order=asc&site=softwareengineering') 106 | .reply(200, {}) 107 | filter.site = 'softwareengineering' 108 | context.questions.questions(filter, function (err, results) { 109 | if (err) throw err 110 | done() 111 | }) 112 | }) 113 | 114 | it('upvote/downvote should throw an error without key or access_token', function () { 115 | function check (fn) { 116 | const cases = [{}, { key: 'fhqwhgads' }, { access_token: 'fhqwhgads' }] 117 | cases.forEach((filter) => { 118 | context.questions[fn](filter, '51812', (err) => { 119 | expect(err.message).to.equal(`questions.${fn} lacks key and/or access token as criteria`) 120 | }) 121 | }) 122 | } 123 | 124 | check('upvote') 125 | check('downvote') 126 | }) 127 | 128 | it('upvote/downvote should throw an error if no callback supplied', function () { 129 | const filter = { key: 'fhqwhgads', access_token: 'fhqwhgads' } 130 | 131 | expect(() => { context.questions.upvote(filter, '51812') }).to.throw() 132 | expect(() => { context.questions.downvote(filter, '51812') }).to.throw() 133 | }) 134 | 135 | { 136 | const filter = { key: 'fhqwhgads', access_token: 'fhqwhgads' } 137 | { 138 | it('upvote should post to expected endpoints', function (done) { 139 | nockScope.post('/2.2/questions/42/upvote', filter) 140 | .reply(200, zlib.deflateSync(Buffer.from(JSON.stringify(questionFixture)))) 141 | 142 | context.questions.upvote(filter, '42', (err, question) => { 143 | expect(err).to.be.undefined 144 | expect(question).to.deep.equal(questionFixture) 145 | done() 146 | }) 147 | }) 148 | 149 | it('downvote should post to expected endpoints', function (done) { 150 | nockScope.post('/2.2/questions/42/downvote', filter) 151 | .reply(200, zlib.deflateSync(Buffer.from(JSON.stringify(questionFixture)))) 152 | 153 | context.questions.downvote(filter, '42', (err, question) => { 154 | expect(err).to.be.undefined 155 | expect(question).to.deep.equal(questionFixture) 156 | done() 157 | }) 158 | }) 159 | 160 | it('upvote with undo should post to expected endpoints', function (done) { 161 | nockScope.post('/2.2/questions/42/upvote/undo', filter) 162 | .reply(200, zlib.deflateSync(Buffer.from(JSON.stringify(questionFixture)))) 163 | 164 | context.questions.upvote(filter, '42', (err, question) => { 165 | expect(err).to.be.undefined 166 | expect(question).to.deep.equal(questionFixture) 167 | done() 168 | }, true) 169 | }) 170 | 171 | it('downvote with undo should post to expected endpoints', function (done) { 172 | nockScope.post('/2.2/questions/42/downvote/undo', filter) 173 | .reply(200, zlib.deflateSync(Buffer.from(JSON.stringify(questionFixture)))) 174 | 175 | context.questions.downvote(filter, '42', (err, question) => { 176 | expect(err).to.be.undefined 177 | expect(question).to.deep.equal(questionFixture) 178 | done() 179 | }, true) 180 | }) 181 | 182 | it('uses error argument in callback for invalid zip', function (done) { 183 | nockScope.post('/2.2/questions/101010/upvote', filter) 184 | .reply(200, 'fhqwhgads') 185 | 186 | context.questions.upvote(filter, '101010', (err, question) => { 187 | expect(question).to.be.undefined 188 | expect(err.message).to.equal('incorrect header check') 189 | done() 190 | }) 191 | }) 192 | 193 | it('uses error argument in callback for invalid JSON', function (done) { 194 | nockScope.post('/2.2/questions/101010/downvote', filter) 195 | .reply(200, zlib.deflateSync(Buffer.from('fhqwhgads'))) 196 | 197 | context.questions.downvote(filter, '101010', (err, question) => { 198 | expect(question).to.be.undefined 199 | expect(err.message).to.equal('Unexpected token h in JSON at position 1') 200 | done() 201 | }) 202 | }) 203 | 204 | it('reports error from request', function (done) { 205 | nockScope.post('/2.2/questions/1/upvote', filter) 206 | .replyWithError({ message: 'come on', code: 'ETIMEDOUT' }) 207 | 208 | context.questions.upvote(filter, '1', (err, question) => { 209 | expect(question).to.be.undefined 210 | expect(err.message).to.include('come on') 211 | expect(err.code).to.be.equal('ETIMEDOUT') 212 | done() 213 | }) 214 | }) 215 | } 216 | } 217 | }) 218 | -------------------------------------------------------------------------------- /test/search-test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai') 2 | const stackexchange = require('../lib/stackexchange') 3 | const nock = require('nock') 4 | 5 | const nockScope = nock('https://api.stackexchange.com', { allowUnmocked: true }) 6 | 7 | describe('Search', function () { 8 | 'use strict' 9 | 10 | let context, filter 11 | 12 | beforeEach(function () { 13 | context = new stackexchange() 14 | filter = { 15 | pagesize: 10, 16 | order: 'desc', 17 | sort: 'activity' 18 | } 19 | }) 20 | 21 | function expectQuestionProperty (item) { 22 | const props = ['tags', 'owner', 'is_answered', 'view_count', 'answer_count', 23 | 'score', 'last_activity_date', 'creation_date', 24 | 'question_id', 'link', 'title'] 25 | props.forEach((prop) => expect(item).to.have.property(prop)) 26 | } 27 | 28 | it('does simple search', function (done) { 29 | filter.intitle = 'nodejs' 30 | context.search.search(filter, function (err, results) { 31 | if (err) throw err 32 | 33 | expect(results.items).to.have.length(10) 34 | results.items.forEach((item) => { expectQuestionProperty(item) }) 35 | expect(results.has_more).to.be.true 36 | 37 | done() 38 | }) 39 | }) 40 | 41 | it('does advanced search', function (done) { 42 | filter.q = 'nodejs' 43 | context.search.advanced(filter, function (err, results) { 44 | if (err) throw err 45 | 46 | expect(results.items).to.have.length(10) 47 | results.items.forEach((item) => { expectQuestionProperty(item) }) 48 | expect(results.has_more).to.be.true 49 | 50 | done() 51 | }) 52 | }) 53 | 54 | it('reports invalid JSON via error object of callback', function (done) { 55 | nockScope.get('/2.2/search?pagesize=10&order=desc&sort=activity&q=fhqwhgads&site=stackoverflow') 56 | .reply(200, 'fhqwhgads') 57 | 58 | filter.q = 'fhqwhgads' 59 | context.search.search(filter, function (err, results) { 60 | expect(results).to.be.undefined 61 | expect(err.message).to.include('Unexpected token h in JSON at position 1') 62 | done() 63 | }) 64 | }) 65 | 66 | it('reports error from request', function (done) { 67 | nockScope.get('/2.2/search?pagesize=10&order=desc&sort=activity&q=42&site=stackoverflow') 68 | .replyWithError({ message: 'come on', code: 'ETIMEDOUT' }) 69 | 70 | filter.q = '42' 71 | context.search.search(filter, function (err, results) { 72 | expect(results).to.be.undefined 73 | expect(err.message).to.include('come on') 74 | expect(err.code).to.be.equal('ETIMEDOUT') 75 | done() 76 | }) 77 | }) 78 | }) 79 | -------------------------------------------------------------------------------- /test/tags-test.js: -------------------------------------------------------------------------------- 1 | /* tags expect */ 2 | 3 | const { expect } = require('chai') 4 | const stackexchange = require('../lib/stackexchange') 5 | 6 | describe('Tags', function () { 7 | 'use strict' 8 | 9 | let options, context, filter 10 | 11 | beforeEach(function () { 12 | options = { version: 2.2 } 13 | context = new stackexchange(options) 14 | filter = { 15 | pagesize: 10, 16 | sort: 'popular', 17 | order: 'desc' 18 | } 19 | }) 20 | 21 | function expectTagProperty (item) { 22 | expect(item).to.have.property('has_synonyms') 23 | expect(item).to.have.property('is_moderator_only') 24 | expect(item).to.have.property('is_required') 25 | expect(item).to.have.property('count') 26 | expect(item).to.have.property('name') 27 | } 28 | function expectSynonymsProperty (item) { 29 | expect(item).to.have.property('creation_date') 30 | expect(item).to.have.property('applied_count') 31 | expect(item).to.have.property('to_tag') 32 | expect(item).to.have.property('from_tag') 33 | } 34 | 35 | it('get tags', function (done) { 36 | context.tags.tags(filter, function (err, results) { 37 | if (err) throw err 38 | // console.log('results: ', results); 39 | 40 | expect(results.items).to.have.length(10) 41 | expectTagProperty(results.items[0]) 42 | expect(results.has_more).to.be.true 43 | done() 44 | }) 45 | }) 46 | 47 | it('get tags requires sort option', function (done) { 48 | delete filter.sort 49 | context.tags.tags(filter, function (err, results) { 50 | expect(err).to.be.instanceof(Error) 51 | done() 52 | }) 53 | }) 54 | 55 | it('get tags illegal sort option', function (done) { 56 | filter.sort = 'creation' 57 | context.tags.tags(filter, function (err, results) { 58 | expect(err).to.be.instanceof(Error) 59 | done() 60 | }) 61 | }) 62 | 63 | it('get tags info', function (done) { 64 | const tags = ['javascript', 'ruby'] 65 | context.tags.info(filter, function (err, results) { 66 | if (err) throw err 67 | // console.log('results: ', results); 68 | 69 | expect(results.items).to.have.length(2) 70 | expectTagProperty(results.items[0]) 71 | expect(results.has_more).to.be.false 72 | done() 73 | }, tags) 74 | }) 75 | 76 | it('get tags info has error with empty tags', function (done) { 77 | context.tags.info(filter, function (err, results) { 78 | expect(err).to.be.instanceof(Error) 79 | done() 80 | }, []) 81 | }) 82 | 83 | it('get tags moderatorOnly return empty', function (done) { 84 | context.tags.moderatorOnly(filter, function (err, results) { 85 | if (err) throw err 86 | // console.log('results: ', results); 87 | 88 | expect(results.items).to.have.length(0) 89 | expect(results.has_more).to.be.false 90 | done() 91 | }) 92 | }) 93 | 94 | it('get tags required return empty', function (done) { 95 | context.tags.required(filter, function (err, results) { 96 | if (err) throw err 97 | // console.log('results: ', results); 98 | 99 | expect(results.items).to.have.length(0) 100 | expect(results.has_more).to.be.false 101 | done() 102 | }) 103 | }) 104 | 105 | it('get tags synonyms', function (done) { 106 | filter.sort = 'creation' 107 | context.tags.synonyms(filter, function (err, results) { 108 | if (err) throw err 109 | // console.log('results: ', results); 110 | 111 | expect(results.items).to.have.length(10) 112 | expectSynonymsProperty(results.items[0]) 113 | expect(results.has_more).to.be.true 114 | done() 115 | }) 116 | }) 117 | 118 | it('get tags synonyms requires sort option', function (done) { 119 | delete filter.sort 120 | context.tags.synonyms(filter, function (err, results) { 121 | expect(err).to.be.instanceof(Error) 122 | done() 123 | }) 124 | }) 125 | 126 | it('get tags synonyms illegal sort option', function (done) { 127 | context.tags.synonyms(filter, function (err, results) { 128 | expect(err).to.be.instanceof(Error) 129 | done() 130 | }) 131 | }) 132 | 133 | it('get tags faq', function (done) { 134 | context.tags.faq(filter, function (err, results) { 135 | if (err) throw err 136 | // console.log('results: ', results); 137 | 138 | expect(results.items).to.have.length(10) 139 | expect(results.has_more).to.be.true 140 | done() 141 | }, ['java', 'c']) 142 | }) 143 | 144 | it('get tags related', function (done) { 145 | context.tags.related(filter, function (err, results) { 146 | if (err) throw err 147 | // console.log('results: ', results); 148 | 149 | expect(results.items).to.have.length(10) 150 | expectTagProperty(results.items[0]) 151 | expect(results.has_more).to.be.true 152 | done() 153 | }, ['java', 'javascript']) 154 | }) 155 | 156 | it('get tags tagsSynonyms', function (done) { 157 | filter.sort = 'creation' 158 | context.tags.tagsSynonyms(filter, function (err, results) { 159 | if (err) throw err 160 | // console.log('results: ', results); 161 | 162 | // expect(results.items).to.have.length(10); 163 | expectSynonymsProperty(results.items[0]) 164 | // expect(results.has_more).to.be.true; 165 | done() 166 | }, ['javascript']) 167 | }) 168 | 169 | it('get tags topAnswerers', function (done) { 170 | context.tags.topAnswerers(filter, function (err, results) { 171 | if (err) throw err 172 | // console.log('results: ', results); 173 | 174 | expect(results.items).to.have.length(10) 175 | expect(results.has_more).to.be.true 176 | done() 177 | }, ['javascript'], 'all_time') 178 | }) 179 | 180 | it('get tags topAnswerers omitting period', function (done) { 181 | context.tags.topAnswerers(filter, function (err, results) { 182 | if (err) throw err 183 | // console.log('results: ', results); 184 | 185 | expect(results.items).to.have.length(10) 186 | expect(results.has_more).to.be.true 187 | done() 188 | }, ['javascript']) 189 | }) 190 | 191 | it('get tags topAnswerers illegal period', function (done) { 192 | context.tags.topAnswerers(filter, function (err, results) { 193 | expect(err).to.be.instanceof(Error) 194 | done() 195 | }, ['javascript'], 'all_days') 196 | }) 197 | 198 | it('get tags topAskers', function (done) { 199 | context.tags.topAskers(filter, function (err, results) { 200 | if (err) throw err 201 | // console.log('results: ', results); 202 | 203 | expect(results.items).to.have.length(10) 204 | expect(results.has_more).to.be.true 205 | done() 206 | }, ['javascript']) 207 | }) 208 | 209 | it('get tags topAskers illegal period', function (done) { 210 | context.tags.topAskers(filter, function (err, results) { 211 | expect(err).to.be.instanceof(Error) 212 | done() 213 | }, ['javascript'], 'all_days') 214 | }) 215 | 216 | it('get tags wiki', function (done) { 217 | context.tags.wiki(filter, function (err, results) { 218 | if (err) throw err 219 | // console.log('results: ', results); 220 | 221 | expect(results.items).to.have.length(1) 222 | expect(results.items[0]).to.have.property('excerpt_last_edit_date') 223 | expect(results.items[0]).to.have.property('body_last_edit_date') 224 | expect(results.items[0]).to.have.property('excerpt') 225 | expect(results.items[0]).to.have.property('tag_name') 226 | expect(results.has_more).to.be.false 227 | done() 228 | }, ['javascript']) 229 | }) 230 | 231 | it('get tags wiki errors if no tag provided', function (done) { 232 | context.tags.wiki(filter, function (err, results) { 233 | expect(err).to.be.instanceof(Error) 234 | expect(err.message).to.equal('tags is required') 235 | expect(results).to.be.undefined 236 | done() 237 | }, []) 238 | }) 239 | }) 240 | -------------------------------------------------------------------------------- /test/users-test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai') 2 | const stackexchange = require('../lib/stackexchange') 3 | 4 | describe('Users', function () { 5 | 'use strict' 6 | 7 | let context, filter 8 | 9 | beforeEach(function () { 10 | context = new stackexchange() 11 | filter = { 12 | pagesize: 10 13 | } 14 | }) 15 | 16 | function expectUserProperty (item) { 17 | const props = ['badge_counts', 'account_id', 'is_employee', 18 | 'last_modified_date', 'last_access_date', 19 | 'reputation_change_year', 'reputation_change_quarter', 20 | 'reputation_change_month', 'reputation_change_week', 21 | 'reputation_change_day', 'reputation', 'creation_date', 22 | 'user_type', 'user_id', 'location', 'website_url', 'link', 23 | 'profile_image', 'display_name'] 24 | props.forEach((prop) => expect(item).to.have.property(prop)) 25 | } 26 | 27 | function expectedAnswerProperty (item) { 28 | const props = ['owner', 'is_accepted', 'score', 'last_activity_date', 29 | 'creation_date', 'answer_id', 'question_id', 30 | 'content_license'] 31 | props.forEach((prop) => expect(item).to.have.property(prop)) 32 | } 33 | 34 | it('get users and get answers for user', function (done) { 35 | context.users.users(filter, function (err, results) { 36 | if (err) throw err 37 | 38 | expect(results.items).to.have.length(10) 39 | results.items.forEach((item) => { expectUserProperty(item) }) 40 | expect(results.has_more).to.be.true 41 | 42 | const userIds = results.items.map((items) => items.user_id) 43 | 44 | context.users.answers(filter, userIds, function (err, results) { 45 | if (err) throw err 46 | 47 | expect(results.items).to.have.length(10) 48 | results.items.forEach((item) => { expectedAnswerProperty(item) }) 49 | expect(results.has_more).to.be.true 50 | 51 | done() 52 | }) 53 | }) 54 | }) 55 | 56 | it('throws if answers does not get an array of ids', function (done) { 57 | function checkError (err) { 58 | expect(err).to.be.instanceof(Error) 59 | expect(err.message).to.equal('users.answers lacks IDs to query') 60 | } 61 | 62 | context.users.answers(filter, 0, checkError) 63 | context.users.answers(filter, [], (err) => { checkError(err); done() }) 64 | }) 65 | }) 66 | --------------------------------------------------------------------------------