├── .gitignore ├── LICENSE ├── README.md ├── example.config.json ├── lib ├── cache.js ├── helper.js ├── method.js └── timer.js ├── methods ├── categories.js ├── download.js ├── file.js ├── files.js ├── gameVersions.js ├── list.js ├── project.js ├── relations.js └── search.js ├── package.json ├── pm2.json ├── server.js └── views └── index.html /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | config.json 3 | npm-debug.log 4 | .idea/ 5 | ssl/ 6 | package-lock.json 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Aternos UG (haftungsbeschränkt) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simple CurseForge API 2 | 3 | Please use this in a reasonable way and don't spam CurseForge with requests. 4 | There is caching implemented in this project that you can configure, please install Redis 5 | and use this cache. It's also recommended to set your own user agent in the config to avoid 6 | accidental blocks and to identify yourself to CurseForge. 7 | 8 | ### Setup 9 | 10 | 1. Clone this repository 11 | 2. `npm install` 12 | 3. Copy example.config.json to config.json and edit accordingly 13 | 4. `node server.js` 14 | 15 | ### Usage 16 | See [views/index.html](views/index.html) or open the configured port in your browser. -------------------------------------------------------------------------------- /example.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "port": 3000, 3 | "caching": true, 4 | "cache": { 5 | "success": 3600000, 6 | "error": 600000 7 | }, 8 | "baseUrl": "https://curseforge.com/", 9 | "rediscache": false, 10 | "redis": { 11 | "host": "127.0.0.1", 12 | "port": 6379 13 | }, 14 | "graylog": { 15 | "enabled": false, 16 | "host": "host", 17 | "port": 12201 18 | }, 19 | "ssl":{ 20 | "cert": "./path/to/cert", 21 | "key": "./path/to/key" 22 | }, 23 | "userAgent": "curseforge-api/1.0.0" 24 | } 25 | -------------------------------------------------------------------------------- /lib/cache.js: -------------------------------------------------------------------------------- 1 | const redis = require('redis'); 2 | const config = require('../config'); 3 | 4 | let redisclient = null; 5 | if(config.rediscache){ 6 | redisclient = redis.createClient(config.redis); 7 | } 8 | 9 | let memoryCache = {}; 10 | 11 | /** 12 | * Cache middleware function 13 | * 14 | * @param req 15 | * @param res 16 | * @param next 17 | * @returns {Promise} 18 | */ 19 | async function middleware(req, res, next){ 20 | const key = req.headers.host + (req.originalUrl || req.url); 21 | 22 | res.on('finish',async function(){ 23 | if(res.isCached){ 24 | return; 25 | } 26 | let cacheObj = { 27 | headers: res.getHeaders() || {}, 28 | body: res.rawbody || '', 29 | status: res.statusCode 30 | }; 31 | await set(key, JSON.stringify(cacheObj), res.isSuccess() ? config.cache.success : config.cache.error); 32 | }); 33 | 34 | let cachedResponse = await get(key); 35 | if(!cachedResponse){ 36 | saveRespBody(res); 37 | res.isCached = false; 38 | next(); 39 | return; 40 | } 41 | try{ 42 | cachedResponse = JSON.parse(cachedResponse); 43 | }catch (e) { 44 | saveRespBody(res); 45 | res.isCached = false; 46 | next(); 47 | return; 48 | } 49 | res.isCached = true; 50 | res.status(cachedResponse.status || 200); 51 | res.set(cachedResponse.headers || {}); 52 | res.send(cachedResponse.body); 53 | } 54 | 55 | /** 56 | * Overwrite write and end function of Response object 57 | * Save all sent data as string in Response.rawbody 58 | * 59 | * @param res 60 | */ 61 | function saveRespBody(res) { 62 | const origWrite = res.write; 63 | const origEnd = res.end; 64 | res.rawbody = ''; 65 | 66 | res.write = function(...args){ 67 | res.rawbody += String(args[0] || ''); 68 | origWrite.apply(res,args); 69 | }; 70 | res.end = function(...args){ 71 | res.rawbody += String(args[0] || ''); 72 | origEnd.apply(res,args); 73 | } 74 | } 75 | 76 | /** 77 | * Set value in cache 78 | * 79 | * @param key 80 | * @param value 81 | * @param duration 82 | * @returns {Promise} 83 | */ 84 | function set(key, value, duration = 1000*60*60){ 85 | return new Promise(function (resolve, reject) { 86 | if(redisclient){ 87 | redisclient.hset(key, "response", value, function (err) { 88 | if(err){ 89 | return reject(err); 90 | } 91 | redisclient.hset(key, "duration", duration, function (err) { 92 | if(err){ 93 | return reject(err); 94 | } 95 | redisclient.expire(key, duration/1000, function (err) { 96 | if(err){ 97 | return reject(err); 98 | } 99 | resolve(); 100 | }); 101 | }); 102 | }); 103 | }else{ 104 | memoryCache[key] = { 105 | value: value, 106 | expires: Date.now() + duration 107 | }; 108 | resolve(); 109 | } 110 | }); 111 | } 112 | 113 | /** 114 | * Get value from cache 115 | * 116 | * @param key 117 | * @returns {Promise} 118 | */ 119 | function get(key) { 120 | return new Promise(function (resolve) { 121 | if(redisclient){ 122 | redisclient.hgetall(key, function (err, obj) { 123 | if(err || !obj){ 124 | return resolve(null); 125 | } 126 | resolve(obj.response); 127 | }); 128 | }else { 129 | if(!memoryCache[key] || Date.now() > memoryCache[key].expires){ 130 | return resolve(null); 131 | } 132 | resolve(memoryCache[key].value); 133 | } 134 | }); 135 | } 136 | 137 | module.exports = { 138 | middleware, 139 | set, 140 | get 141 | }; 142 | -------------------------------------------------------------------------------- /lib/helper.js: -------------------------------------------------------------------------------- 1 | let helper = {}; 2 | 3 | /** 4 | * Get status code from HTTP response 5 | * 6 | * @param resp 7 | * @returns {*} 8 | */ 9 | helper.getStatusCode = function(resp){ 10 | return resp ? (resp.statusCode || 500) : 500; 11 | }; 12 | 13 | helper.parseFileList = function($, rows, baseUrl, downloadUrl){ 14 | let data = []; 15 | rows.each(function () { 16 | const row = this; 17 | const cells = $(row).children('td'); 18 | let file = {}; 19 | file.type = $(cells[0]).text().trim(); 20 | file.name = $(cells[1]).children('a:not(.rounded)').text().trim(); 21 | file.url = baseUrl + $(cells[1]).find("a").attr('href').substr(1); 22 | file.id = parseInt(file.url.split('/').pop()); 23 | file.size = $(cells[2]).text().trim(); 24 | file.date = parseInt($(cells[3]).children('abbr.standard-date').attr('data-epoch')); 25 | file.supportedVersion = $(cells[4]).find('.mr-2').text().trim(); 26 | file.download = `${baseUrl}${downloadUrl}/${file.id}/file`; 27 | data.push(file); 28 | }); 29 | return data; 30 | }; 31 | 32 | helper.parseProjectList = function($, rows, baseUrl){ 33 | let data = []; 34 | rows.each(function () { 35 | let project = {}; 36 | project.avatar = $(this).find('div.project-avatar > a > img').attr('src'); 37 | 38 | let projectInfo = $(this).find('div.flex.flex-col').children(); 39 | let links = $(projectInfo[0]).children('a'); 40 | project.title = $(links[0]).text().trim(); 41 | project.url = baseUrl + $(links[0]).attr('href').substr(1); 42 | project.slug = project.url.split('/').pop(); 43 | project.author = $(links[1]).attr('href').split('/').pop(); 44 | 45 | let infoFields = $(projectInfo[1]).children('span'); 46 | let downloads = $(infoFields[0]).text().trim().split(' ')[0]; 47 | const downloadMultiplier = { 48 | K: 1000, 49 | M: 1000000 50 | }; 51 | if(isNaN(parseInt(downloads.slice(-1)))){ 52 | project.downloads = Math.round(parseFloat(downloads) * downloadMultiplier[downloads.slice(-1)]); 53 | }else{ 54 | project.downloads = parseInt(downloads); 55 | } 56 | project.updated = parseInt($(infoFields[1]).children('abbr.standard-date').attr('data-epoch')); 57 | project.created = parseInt($(infoFields[2]).children('abbr.standard-date').attr('data-epoch')); 58 | project.shortdescription = $(projectInfo[2]).text().trim(); 59 | project.categories = []; 60 | let categoryLinks = $(this).children('div.w-full').find('a').filter(function(){ 61 | return $(this).children('figure.relative').length === 1; 62 | }); 63 | $(categoryLinks).each(function(){ 64 | project.categories.push($(this).attr('href').split('/').splice(3).join('/')); 65 | }); 66 | 67 | data.push(project); 68 | }); 69 | return data; 70 | }; 71 | 72 | helper.paginationInfo = function($){ 73 | const paginationElem = $('.pagination.pagination-top'); 74 | let pagination = { 75 | exists: false, 76 | pages: [], 77 | lastPage: false, 78 | firstPage: false, 79 | last: 1 80 | }; 81 | if ($(paginationElem).length > 0) { 82 | pagination.exists = true; 83 | for (let part of $(paginationElem).text().split('\n')) { 84 | let partTrimmed = part.trim(); 85 | if (partTrimmed.length > 0) { 86 | pagination.pages.push(partTrimmed); 87 | } 88 | } 89 | if(pagination.pages.length){ 90 | pagination.last = parseInt(pagination.pages[pagination.pages.length - 1]); 91 | pagination.last = isNaN(pagination.last) ? null : pagination.last; 92 | } 93 | if($(paginationElem).children('.pagination-next').hasClass("pagination-next--inactive")){ 94 | pagination.lastPage = true; 95 | } 96 | if($(paginationElem).children('.pagination-prev').hasClass("pagination-next--inactive")){ 97 | pagination.firstPage = true; 98 | } 99 | } 100 | return pagination; 101 | }; 102 | 103 | helper.getResponseHeaders = async function(url, got){ 104 | const request = got(url); 105 | let response = null; 106 | request.on('response', resp => { 107 | response = resp; 108 | request.cancel(); 109 | }); 110 | try { 111 | await request; 112 | }catch (e) { 113 | 114 | } 115 | if(!response){ 116 | throw new Error('Could not fetch headers'); 117 | } 118 | return response; 119 | }; 120 | 121 | helper.getGameVersions = function($){ 122 | let versions = []; 123 | $('#filter-game-version > option').each(function () { 124 | if($(this).attr('value') === ''){ 125 | return; 126 | } 127 | versions.push({ 128 | name: $(this).text().trim(), 129 | id: $(this).attr('value') 130 | }); 131 | }); 132 | return versions; 133 | }; 134 | 135 | module.exports = helper; 136 | -------------------------------------------------------------------------------- /lib/method.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Class representing an API method 3 | * 4 | * @abstract 5 | */ 6 | class Method{ 7 | /** 8 | * Method constructor 9 | * 10 | * @param config 11 | * @param cache 12 | * @param got 13 | * @param cheerio 14 | */ 15 | constructor(config, cache, got = null, cheerio = null){ 16 | this.config = config; 17 | this.gotModule = got || require('got'); 18 | this.cheerio = cheerio || require('cheerio'); 19 | this.cache = cache; 20 | } 21 | 22 | async httpReq(url, options = {}){ 23 | if(options.cacheable !== false){ 24 | let cached = await this.cache.get(`got-req-${url}`); 25 | if(cached){ 26 | let response = JSON.parse(cached); 27 | if(response.statusCode !== 200){ 28 | throw new this.gotModule.HTTPError(response, options); 29 | } 30 | return response.body; 31 | } 32 | } 33 | let response = await this.gotModule(url, Object.assign({}, options, { 34 | throwHttpErrors: false 35 | })); 36 | if(options.cacheable !== false){ 37 | await this.cache.set(`got-req-${url}`, JSON.stringify({ 38 | statusCode: response.statusCode, 39 | body: response.body 40 | }), response.statusCode === 200 ? this.config.cache.success : this.config.cache.error); 41 | } 42 | if(response.statusCode !== 200){ 43 | throw new this.gotModule.HTTPError(response, options); 44 | } 45 | return response.body; 46 | } 47 | 48 | /** 49 | * Register method route in express app 50 | * 51 | * @param app 52 | */ 53 | register(app){ 54 | const self = this; 55 | app.get(this.route,async function(req, res, next){ 56 | if(!self.condition(req,res)){ 57 | return next(); 58 | } 59 | try{ 60 | await self.call(req, res); 61 | }catch(e){ 62 | res.serverError(e); 63 | console.error(e.stack || e); 64 | } 65 | }); 66 | } 67 | 68 | /** 69 | * Additional condition for this method to be executed 70 | * 71 | * @param req 72 | * @param res 73 | * @returns {boolean} 74 | */ 75 | condition(req,res){ 76 | return true; 77 | } 78 | 79 | /** 80 | * Method express route 81 | * 82 | * @abstract 83 | * @returns {string} 84 | */ 85 | get route(){ 86 | return ''; 87 | } 88 | 89 | /** 90 | * Execute method, write response 91 | * Content-Type is set to application/json by default 92 | * 93 | * @abstract 94 | * @param req 95 | * @param res 96 | * @returns {Promise} 97 | */ 98 | async call(req, res){ 99 | res.serverError('not implemented'); 100 | } 101 | } 102 | 103 | module.exports = Method; 104 | -------------------------------------------------------------------------------- /lib/timer.js: -------------------------------------------------------------------------------- 1 | class Timer{ 2 | /** 3 | * Timer constructor 4 | */ 5 | constructor(){ 6 | this.timers = {}; 7 | } 8 | 9 | /** 10 | * Start a new timer 11 | * 12 | * @param name 13 | * @returns {Timer} 14 | */ 15 | start(name){ 16 | name = String(name); 17 | this.timers[name] = { 18 | start: Date.now(), 19 | stop: null 20 | }; 21 | return this; 22 | } 23 | 24 | /** 25 | * Stop a timer 26 | * 27 | * @param name 28 | * @returns {Timer} 29 | */ 30 | stop(name){ 31 | name = String(name); 32 | if(!this.timers[name]){ 33 | throw new Error(`Timer ${name.toUpperCase()} has not been started yet`); 34 | } 35 | if(this.timers[name].stop){ 36 | console.warn(`Timer ${name.toUpperCase()} has already been stopped`); 37 | } 38 | this.timers[name].stop = Date.now(); 39 | return this; 40 | } 41 | 42 | /** 43 | * Get current value of a timer 44 | * 45 | * @param name 46 | * @returns {number} 47 | */ 48 | time(name){ 49 | if(!this.timers[name]){ 50 | throw new Error(`Timer ${name.toUpperCase()} has not been started yet`); 51 | } 52 | if(!this.timers[name].stop){ 53 | return Date.now() - this.timers[name].start; 54 | } 55 | return this.timers[name].stop - this.timers[name].start; 56 | } 57 | 58 | /** 59 | * Get formatted debug getMessage for all timers 60 | * 61 | * @param info 62 | * @returns {string} 63 | */ 64 | getMessage(info = null){ 65 | let maxLength = 0; 66 | for(let name in this.timers){ 67 | if(!this.timers.hasOwnProperty(name) || !this.timers[name].stop){ 68 | continue; 69 | } 70 | maxLength = name.length > maxLength ? name.length : maxLength; 71 | } 72 | let msgs = []; 73 | msgs.push(`[TIMERS${info ? ` ${info}` : ''}]`); 74 | for(let name in this.timers){ 75 | if(!this.timers.hasOwnProperty(name) || !this.timers[name].stop){ 76 | continue; 77 | } 78 | let padding = (' ').repeat(maxLength - name.length); 79 | msgs.push(`[${padding}${name.toUpperCase()}] ${this.time(name) / 1000}s`) 80 | } 81 | return msgs.join('\n'); 82 | } 83 | toString(){ 84 | return this.getMessage(); 85 | } 86 | } 87 | 88 | module.exports = Timer; 89 | -------------------------------------------------------------------------------- /methods/categories.js: -------------------------------------------------------------------------------- 1 | const Method = require('../lib/method'); 2 | 3 | class CategoriesMethod extends Method { 4 | get route() { 5 | return '/other/:game/:type/categories'; 6 | } 7 | 8 | async call(req, res) { 9 | const game = req.params.game; 10 | const type = req.params.type; 11 | const url = `${this.config.baseUrl}${game}/${type}`; 12 | 13 | let response = await this.httpReq(url); 14 | const $ = this.cheerio.load(response); 15 | 16 | let data = []; 17 | 18 | const categoryFields = $('div.category-list-item > div > a'); 19 | $(categoryFields).each(function(){ 20 | if(!$(this).attr('href')){ 21 | return; 22 | } 23 | let category = {}; 24 | category.displayname = $(this).find('span.whitespace-no-wrap').text().trim(); 25 | category.name = $(this).attr('href').split('/').splice(3).join('/'); 26 | category.icon = $(this).find('figure.relative > img').attr('src'); 27 | data.push(category); 28 | }); 29 | 30 | await res.json(data); 31 | } 32 | 33 | 34 | } 35 | 36 | module.exports = CategoriesMethod; 37 | -------------------------------------------------------------------------------- /methods/download.js: -------------------------------------------------------------------------------- 1 | const Method = require('../lib/method'); 2 | const helper = require('../lib/helper'); 3 | 4 | class DownloadMethod extends Method { 5 | get route() { 6 | return '/:game/:type/:slug/download/:file'; 7 | } 8 | 9 | async call(req, res) { 10 | let url = this.config.baseUrl + req.params.game + '/' + req.params.type + '/' + req.params.slug + '/download/' + req.params.file + '/file'; 11 | req.timers.start('fetch'); 12 | const response = await helper.getResponseHeaders(url, this.gotModule); 13 | req.timers.stop('fetch'); 14 | 15 | let data = {}; 16 | data.id = parseInt(req.params.file); 17 | data.target = response.url; 18 | data.type = response.headers['content-type']; 19 | data.size = response.headers['content-length']; 20 | await res.json(data); 21 | } 22 | } 23 | 24 | module.exports = DownloadMethod; 25 | -------------------------------------------------------------------------------- /methods/file.js: -------------------------------------------------------------------------------- 1 | const Method = require('../lib/method'); 2 | const helper = require("../lib/helper"); 3 | 4 | class FileMethod extends Method { 5 | get route() { 6 | return '/:game/:type/:slug/files/:fileId'; 7 | } 8 | 9 | async call(req, res) { 10 | const game = req.params.game; 11 | const type = req.params.type; 12 | const slug = req.params.slug; 13 | const fileId = req.params.fileId; 14 | const url = `${this.config.baseUrl}${game}/${type}/${slug}/files/${fileId}`; 15 | 16 | let response = await this.httpReq(url); 17 | const $ = this.cheerio.load(response); 18 | 19 | let data = {}; 20 | 21 | const fileInfoFields = $('div.flex.justify-between > div.flex > span.text-sm:not(.font-bold)'); 22 | const supportedVersionFields = $('section.flex > div > div > div > span.tag'); 23 | 24 | data.type = $('div.flex.justify-between > div.flex.align-center.items-center > div.mr-2').text().trim(); 25 | data.name = $('div.flex.justify-between > div.flex.align-center.items-center > a > h3').text().trim(); 26 | data.filename = $(fileInfoFields[0]).text().trim(); 27 | data.uploadedBy = $('div.flex.justify-between > div.flex > a > span.text-sm:not(.font-bold)').text().trim(); 28 | data.date = parseInt($(fileInfoFields[1]).children('abbr.standard-date').attr('data-epoch')); 29 | data.gameVersion = $(fileInfoFields[2]).text().trim(); 30 | data.size = $(fileInfoFields[3]).text().trim(); 31 | data.downloads = parseInt($(fileInfoFields[4]).text().trim().replace(/,/g, '')); 32 | data.md5 = $(fileInfoFields[5]).text().trim(); 33 | data.supportedVersions = []; 34 | supportedVersionFields.each(function () { 35 | data.supportedVersions.push($(this).text().trim()); 36 | }); 37 | data.additionalFiles = helper.parseFileList($, $('.project-file-listing > tbody > tr'), this.config.baseUrl, `${game}/${type}/${slug}/download/`); 38 | 39 | const changelog = $('div.flex > div.bg-accent.rounded > div.user-content'); 40 | data.changelog = changelog.length ? changelog.html().replace(/(\s)+/g, " ").trim() : ''; 41 | data.rawchangelog = changelog.length ? changelog.text().replace(/(\s)+/g, " ").trim() : ''; 42 | 43 | await res.json(data); 44 | } 45 | 46 | 47 | } 48 | 49 | module.exports = FileMethod; 50 | -------------------------------------------------------------------------------- /methods/files.js: -------------------------------------------------------------------------------- 1 | const Method = require('../lib/method'); 2 | const helper = require("../lib/helper"); 3 | 4 | class FilesMethod extends Method { 5 | get route() { 6 | return '/:game/:type/:slug/files'; 7 | } 8 | 9 | async call(req, res) { 10 | let single = false; 11 | let page = 1; 12 | if (req.query.page !== undefined) { 13 | single = true; 14 | page = parseInt(req.query.page); 15 | } 16 | const game = req.params.game; 17 | const type = req.params.type; 18 | const slug = req.params.slug; 19 | 20 | const baseUrl = this.config.baseUrl; 21 | 22 | let addopts = ""; 23 | if (req.query.sort !== undefined) { 24 | addopts += "&sort=" + req.query.sort; 25 | } 26 | if (req.query['filter-game-version'] !== undefined) { 27 | addopts += "&filter-game-version=" + req.query['filter-game-version']; 28 | }else if (req.query['filter-game-version-name'] !== undefined) { 29 | let response = await this.httpReq(`${this.config.baseUrl}${game}/${type}`); 30 | let version = await helper.getGameVersions(this.cheerio.load(response)) 31 | .filter(v => v.name === req.query['filter-game-version-name'])[0]; 32 | if(version){ 33 | addopts += "&filter-game-version=" + version.id; 34 | } 35 | } 36 | let url = `${baseUrl}${game}/${type}/${slug}/files/all`; 37 | 38 | let data = { 39 | files: [], 40 | pagination: { 41 | page: 1, 42 | lastPage: 1 43 | } 44 | }; 45 | do { 46 | req.timers.start(`page ${page}`); 47 | let response = await this.httpReq(`${url}?page=${page}${addopts}`); 48 | const $ = this.cheerio.load(response); 49 | const rows = $('.project-file-listing > tbody > tr'); 50 | 51 | let pagination = helper.paginationInfo($); 52 | if (pagination.exists && !pagination.pages.includes(String(page))) { 53 | break; 54 | } 55 | data.pagination = { 56 | page: page, 57 | lastPage: pagination.last || 1 58 | }; 59 | data.files = data.files.concat(helper.parseFileList($, rows, baseUrl, `${game}/${type}/${slug}/download/`)); 60 | if (pagination.exists && pagination.lastPage) { 61 | break; 62 | } 63 | if (!pagination.exists) { 64 | break; 65 | } 66 | page++; 67 | } while (!single && page <= 5); 68 | 69 | if (single && data.files.length === 0) { 70 | return res.httpError(404, null); 71 | } 72 | 73 | await res.json(data); 74 | } 75 | 76 | 77 | } 78 | 79 | module.exports = FilesMethod; 80 | -------------------------------------------------------------------------------- /methods/gameVersions.js: -------------------------------------------------------------------------------- 1 | const Method = require('../lib/method'); 2 | const helper = require('../lib/helper'); 3 | 4 | class CategoriesMethod extends Method { 5 | get route() { 6 | return '/other/:game/:type/versions'; 7 | } 8 | 9 | async call(req, res) { 10 | const game = req.params.game; 11 | const type = req.params.type; 12 | 13 | let response = await this.httpReq(`${this.config.baseUrl}${game}/${type}`); 14 | await res.json(helper.getGameVersions(this.cheerio.load(response))); 15 | } 16 | 17 | 18 | } 19 | 20 | module.exports = CategoriesMethod; 21 | -------------------------------------------------------------------------------- /methods/list.js: -------------------------------------------------------------------------------- 1 | const Method = require('../lib/method'); 2 | const helper = require("../lib/helper"); 3 | 4 | class ListMethod extends Method { 5 | get route() { 6 | return '/:game/:type'; 7 | } 8 | 9 | async call(req, res) { 10 | const game = req.params.game; 11 | const type = req.params.type; 12 | const category = req.query.category; 13 | let addopts = (req.query['filter-game-version'] ? `&filter-game-version=${req.query['filter-game-version']}` : ''); 14 | addopts += (req.query['filter-sort'] ? `&filter-sort=${req.query['filter-sort']}` : ''); 15 | const url = `${this.config.baseUrl}${game}/${type}` + (category ? '/' + category : ''); 16 | 17 | let page = 1; 18 | if (req.query.page !== undefined) { 19 | page = parseInt(req.query.page); 20 | } 21 | 22 | let response = await this.httpReq(`${url}?page=${page}${addopts}`); 23 | const $ = this.cheerio.load(response); 24 | const rows = $('div.project-listing-row'); 25 | 26 | let pagination = helper.paginationInfo($); 27 | if (pagination.exists && !pagination.pages.includes(String(page))) { 28 | return res.httpError(404, null); 29 | } 30 | 31 | let data = { 32 | projects: helper.parseProjectList($, rows, this.config.baseUrl), 33 | pagination: { 34 | page: page, 35 | lastPage: pagination.last || 1 36 | } 37 | }; 38 | 39 | await res.json(data); 40 | } 41 | 42 | 43 | } 44 | 45 | module.exports = ListMethod; 46 | -------------------------------------------------------------------------------- /methods/project.js: -------------------------------------------------------------------------------- 1 | const Method = require('../lib/method'); 2 | const config = require('../config'); 3 | 4 | class ProjectMethod extends Method { 5 | get route() { 6 | return '/:game/:type/:slug'; 7 | } 8 | 9 | condition(req, res) { 10 | return req.params.slug !== 'search'; 11 | } 12 | 13 | async call(req, res) { 14 | let url = config.baseUrl + req.params.game + "/" + req.params.type + "/" + req.params.slug; 15 | 16 | req.timers.start('fetch'); 17 | let response = await this.gotModule(url); 18 | req.timers.stop('fetch'); 19 | req.timers.start('parse'); 20 | const $ = this.cheerio.load(response.body); 21 | req.timers.stop('parse'); 22 | 23 | req.timers.start('map'); 24 | let data = {}; 25 | data.id = parseInt(this.getNextSpanContent($, "Project ID")); 26 | data.slug = response.req.path.split("/").pop(); 27 | data.title = $('meta[property="og:title"]').attr("content").trim(); 28 | if (data.title.length === 0) { 29 | data.title = $('.game-header h2').text().trim(); 30 | } 31 | data.shortdescription = $('meta[property="og:description"]').attr("content").trim(); 32 | data.url = config.baseUrl + response.req.path.substr(1); 33 | data.download = config.baseUrl + req.params.game + "/" + req.params.type + "/" + req.params.slug + "/download"; 34 | data.avatar = $('.project-avatar > a > img').attr("src"); 35 | data.created = parseInt($(this.getNextSpan($, "Created")).children('abbr.standard-date').attr('data-epoch')); 36 | data.updated = parseInt($(this.getNextSpan($, "Updated")).children('abbr.standard-date').attr('data-epoch')); 37 | data.downloads = parseInt(this.getNextSpanContent($, "Total Downloads").replace(/,/g, "")); 38 | 39 | const description = $('.project-detail__content'); 40 | data.description = description.html().replace(/(\s)+/g, " ").trim(); 41 | data.rawdescription = description.text().replace(/(\s)+/g, " ").trim(); 42 | 43 | data.categories = []; 44 | let categoryLinks = $('aside > div > div > div > div.flex > div > a').filter(function(){ 45 | return $(this).children('figure.relative').length === 1; 46 | }); 47 | $(categoryLinks).each(function(){ 48 | data.categories.push($(this).attr('href').split('/').splice(3).join('/')); 49 | }); 50 | 51 | req.timers.stop('map'); 52 | req.timers.start('response'); 53 | await res.json(data); 54 | req.timers.stop('response'); 55 | } 56 | 57 | getNextSpanContent($, content) { 58 | return $('span').filter(function () { 59 | return $(this).text().trim() === content; 60 | }).next().text(); 61 | } 62 | 63 | getNextSpan($, content){ 64 | return $('span').filter(function () { 65 | return $(this).text().trim() === content; 66 | }).next() 67 | } 68 | } 69 | 70 | module.exports = ProjectMethod; 71 | -------------------------------------------------------------------------------- /methods/relations.js: -------------------------------------------------------------------------------- 1 | const Method = require('../lib/method'); 2 | const helper = require("../lib/helper"); 3 | 4 | class RelationsMethod extends Method { 5 | get route() { 6 | return '/:game/:type/:slug/relations/:relation'; 7 | } 8 | 9 | condition(req, res) { 10 | return ['dependencies', 'dependents'].includes(req.params.relation); 11 | } 12 | 13 | async call(req, res) { 14 | const game = req.params.game; 15 | const type = req.params.type; 16 | const slug = req.params.slug; 17 | const relation = req.params.relation; 18 | let filter = parseInt(req.query['filter-related-' + relation] || req.query.filter); 19 | let addopts = (!isNaN(filter) ? `&filter-related-${relation}=${filter}` : ''); 20 | const url = `${this.config.baseUrl}${game}/${type}/${slug}/relations/${relation}`; 21 | 22 | let single = false; 23 | let page = 1; 24 | if (req.query.page !== undefined) { 25 | single = true; 26 | page = parseInt(req.query.page); 27 | } 28 | 29 | let data = { 30 | projects: [], 31 | pagination: { 32 | page: 1, 33 | lastPage: 1 34 | } 35 | }; 36 | do { 37 | req.timers.start(`page ${page}`); 38 | let response = await this.httpReq(`${url}?page=${page}${addopts}`); 39 | const $ = this.cheerio.load(response); 40 | const rows = $('ul.listing.listing-project > li:not(.alert)'); 41 | 42 | let pagination = helper.paginationInfo($); 43 | if (pagination.exists && !pagination.pages.includes(String(page))) { 44 | break; 45 | } 46 | data.pagination = { 47 | page: page, 48 | lastPage: pagination.last || 1 49 | }; 50 | data.projects = data.projects.concat(helper.parseProjectList($, rows, this.config.baseUrl)); 51 | if (pagination.exists && pagination.lastPage) { 52 | break; 53 | } 54 | if (!pagination.exists) { 55 | break; 56 | } 57 | page++; 58 | } while (!single && page <= 5); 59 | 60 | if (single && data.projects.length === 0) { 61 | return res.httpError(404, null); 62 | } 63 | 64 | await res.json(data); 65 | } 66 | 67 | 68 | } 69 | 70 | module.exports = RelationsMethod; 71 | -------------------------------------------------------------------------------- /methods/search.js: -------------------------------------------------------------------------------- 1 | const Method = require('../lib/method'); 2 | const helper = require("../lib/helper"); 3 | 4 | class SearchMethod extends Method { 5 | get route() { 6 | return '/:game/:type/search'; 7 | } 8 | 9 | async call(req, res) { 10 | const game = req.params.game; 11 | const type = req.params.type; 12 | const search = req.query.search || ''; 13 | 14 | const url = `${this.config.baseUrl}${game}/${type}/search?search=${search}`; 15 | let response = await this.httpReq(url); 16 | 17 | const $ = this.cheerio.load(response); 18 | const rows = $('div.project-listing-row'); 19 | 20 | let data = helper.parseProjectList($, rows, this.config.baseUrl); 21 | 22 | if(data.length === 0){ 23 | res.status(204); 24 | } 25 | await res.json(data); 26 | } 27 | 28 | 29 | } 30 | 31 | module.exports = SearchMethod; 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "curseforge-api", 3 | "version": "0.0.1", 4 | "description": "Simple API wrapper for the curseforge website", 5 | "main": "server.js", 6 | "scripts": { 7 | "start": "node server.js" 8 | }, 9 | "dependencies": { 10 | "cheerio": "^0.22.0", 11 | "express": "^4.16.4", 12 | "gelf-pro": "^1.3.3", 13 | "got": "^9.6.0", 14 | "redis": "^2.8.0" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /pm2.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "curseforge-api", 3 | "script": "server.js", 4 | "watch": true, 5 | "instances": 8, 6 | "exec_mode": "cluster" 7 | } 8 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | let cheerio = require('cheerio'); 2 | let express = require('express'); 3 | let config = require('./config.json'); 4 | const http = require('http'); 5 | const https = require('https'); 6 | const fsModule = require('fs'); 7 | const fs = fsModule.promises; 8 | const gotModule = require('got'); 9 | const Timer = require('./lib/timer'); 10 | const cache = require('./lib/cache'); 11 | const helper = require("./lib/helper"); 12 | const gelf = require('gelf-pro'); 13 | const os = require('os'); 14 | const timeout = 10000; 15 | let app = express(); 16 | 17 | if(config.graylog.enabled){ 18 | gelf.setConfig({ 19 | fields: {application: "curseforge-api", host: os.hostname()}, 20 | adapterOptions: { 21 | host: config.graylog.host, 22 | port: config.graylog.port 23 | } 24 | }); 25 | } 26 | 27 | const got = gotModule.extend({ 28 | timeout: timeout, 29 | headers: { 30 | 'user-agent': config.userAgent || 'curseforge-api/1.0.0' 31 | }, 32 | hooks: { 33 | init: function(gotReq){ 34 | console.log("FETCHING: " + gotReq.href); 35 | if(config.graylog.enabled){ 36 | gelf.info(`FETCHING: ${gotReq.href}`, { 37 | url: gotReq.href, 38 | path: gotReq.pathname, 39 | query: gotReq.query 40 | }); 41 | } 42 | } 43 | } 44 | }); 45 | 46 | app.use(function (req, res, next) { 47 | if(req.url !== '' && req.url !== '/'){ 48 | res.set('Content-Type', 'application/json'); 49 | } 50 | 51 | req.timers = new Timer(); 52 | req.timers.start('total'); 53 | res.on('finish',function(){ 54 | req.timers.stop('total'); 55 | console.log(req.timers.getMessage(req.url)); 56 | if(config.graylog.enabled){ 57 | gelf.info(`FINISH REQUEST: ${req.url}`, { 58 | total: req.timers.time('total') 59 | }); 60 | } 61 | }); 62 | 63 | /** 64 | * Send JSON error response 65 | * 66 | * @param error 67 | */ 68 | res.serverError = function(error = null){ 69 | let code = 500; 70 | if(error.name === 'HTTPError'){ 71 | code = error.statusCode || 500; 72 | error = error.statusMessage || error.getMessage || null; 73 | }else if(error instanceof Error){ 74 | error = error.message; 75 | } 76 | res.status(code); 77 | res.json({ 78 | error: error || null, 79 | code: code 80 | }); 81 | }; 82 | 83 | /** 84 | * Send custom JSON error response 85 | * 86 | * @param code 87 | * @param error 88 | */ 89 | res.httpError = function(code = 500, error = null){ 90 | if(typeof code === 'object'){ 91 | code = helper.getStatusCode(code); 92 | } 93 | res.status(code); 94 | res.json({ 95 | error: error, 96 | code: code 97 | }); 98 | }; 99 | 100 | res.isSuccess = function(){ 101 | return res.statusCode && ((res.statusCode >= 200 && res.statusCode < 300) || res.statusCode === 405); 102 | }; 103 | next(); 104 | }); 105 | 106 | app.use(function (req, res, next) { 107 | let ip = req.get("x-forwarded-for") || req.ip; 108 | 109 | console.log("REQUESTED: " + req.url + " | " + ip + " | " + req.get("User-Agent")); 110 | next(); 111 | }); 112 | 113 | app.get("/", function (req, res) { 114 | res.sendFile(__dirname + '/views/index.html', {headers: {"content-type": "text/html"}}); 115 | }); 116 | 117 | if (config.caching) { 118 | app.use(cache.middleware); 119 | } 120 | 121 | let methods = []; 122 | (async function(){ 123 | for(let methodName of await fs.readdir('./methods')){ 124 | let method = new (require('./methods/' + methodName))(config, cache, got, cheerio); 125 | method.register(app); 126 | methods.push(method); 127 | } 128 | app.use(function (req, res) { 129 | res.httpError(404, 'Not found') 130 | }); 131 | })(); 132 | 133 | let ssl = false, cert, key; 134 | if(config.ssl){ 135 | try{ 136 | cert = fsModule.readFileSync(config.ssl.cert, 'utf8'); 137 | key = fsModule.readFileSync(config.ssl.key, 'utf8'); 138 | ssl = true; 139 | }catch(e){ 140 | console.log('Could not read ssl cert or key'); 141 | ssl = false; 142 | } 143 | } 144 | 145 | if(ssl){ 146 | https.createServer({cert: cert, key: key}, app) 147 | .listen(config.port, function () { 148 | console.log('HTTPS server listening on port ' + config.port); 149 | }); 150 | }else{ 151 | http.createServer(app) 152 | .listen(config.port, function () { 153 | console.log('HTTP server listening on port ' + config.port); 154 | }); 155 | } 156 | -------------------------------------------------------------------------------- /views/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Simple CurseForge API 5 | 6 | 7 | 8 | 9 | 49 | 50 | 51 |
52 |

Simple CurseForge API

53 | This is a simple API for curseforge.com.
54 | It mostly uses the same URL scheme as the CurseForge website. You can imagine this API like JSON glasses for 55 | CurseForge.
56 | The API tries to directly fetch the data on request, but all requests are cached for 1 hour. A client-side cache is 57 | also recommended.
58 |
59 |
60 |
61 |

Currently supported methods

62 | 63 |

List projects

64 |

/{game}/{type}[?category={category}][&filter-game-version={game_version}][&filter-sort={field}][&page={page}]

65 | /minecraft/mc-mods
66 | 67 |

Search projects

68 |

/{game}/{type}/search?search={search}

69 | /minecraft/mc-mods/search?search=worldedit
70 | 71 |

Project information

72 |

/{game}/{type}/{slug}

73 | /minecraft/mc-mods/worldedit
74 | 75 |

Project relations

76 |

/{game}/{type}/{slug}/relations/{relation}[?filter={field}][&page={page}]

77 | /minecraft/mc-mods/journeymap/relations/dependencies
78 | 79 |

List project files

80 |

/{game}/{type}/{slug}/files[?sort={field}][&page={page}][&filter-game-version={version-id}][&filter-game-version-name={version-name}]

81 | /minecraft/mc-mods/worldedit/files
82 | 83 |

File information

84 |

/{game}/{type}/{slug}/files/{file_id}

85 | /minecraft/mc-mods/worldedit/files/2725648
86 | 87 |

Download information

88 |

/{game}/{type}/{slug}/download/{file_id}

89 | /minecraft/mc-mods/worldedit/download/2725648
90 | 91 |

List categories

92 |

/other/{game}/{type}/categories

93 | /other/minecraft/mc-mods/categories
94 | 95 |

List game versions

96 |

/other/{game}/{type}/versions

97 | /other/minecraft/mc-mods/versions
98 | 99 |
100 |
101 |
102 | 103 | 104 | --------------------------------------------------------------------------------