diff --git a/.gitignore b/.gitignore index c9735e7..4bbcc78 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ node_modules .idea .vscode .history +examples/moddef.json diff --git a/app.js b/app.js index 26e657a..de14190 100644 --- a/app.js +++ b/app.js @@ -1,146 +1,4 @@ #!/usr/bin/env node -const fs = require('fs') -const path = require('path') -const express = require('express') -const bodyParser = require('body-parser') -const request = require('./util/request') -const packageJSON = require('./package.json') -const exec = require('child_process').exec -const cache = require('./util/apicache').middleware -const { cookieToJson } = require('./util/index') -const fileUpload = require('express-fileupload') -const decode = require('safe-decode-uri-component') - -// version check -exec('npm info NeteaseCloudMusicApi version', (err, stdout, stderr) => { - if (!err) { - let version = stdout.trim() - if (packageJSON.version < version) { - console.log( - `最新版本: ${version}, 当前版本: ${packageJSON.version}, 请及时更新`, - ) - } - } +require('./server').serveNcmApi({ + checkVersion: true, }) - -const app = express() -app.set('trust proxy', true) - -// CORS & Preflight request -app.use((req, res, next) => { - if (req.path !== '/' && !req.path.includes('.')) { - res.set({ - 'Access-Control-Allow-Credentials': true, - 'Access-Control-Allow-Origin': req.headers.origin || '*', - 'Access-Control-Allow-Headers': 'X-Requested-With,Content-Type', - 'Access-Control-Allow-Methods': 'PUT,POST,GET,DELETE,OPTIONS', - 'Content-Type': 'application/json; charset=utf-8', - }) - } - req.method === 'OPTIONS' ? res.status(204).end() : next() -}) - -// cookie parser -app.use((req, res, next) => { - req.cookies = {} - //;(req.headers.cookie || '').split(/\s*;\s*/).forEach((pair) => { // Polynomial regular expression // - ;(req.headers.cookie || '').split(/;\s+|(? { - let crack = pair.indexOf('=') - if (crack < 1 || crack == pair.length - 1) return - req.cookies[decode(pair.slice(0, crack)).trim()] = decode( - pair.slice(crack + 1), - ).trim() - }) - next() -}) - -// body parser -app.use(bodyParser.json()) -app.use(bodyParser.urlencoded({ extended: false })) - -app.use(fileUpload()) - -// static -app.use(express.static(path.join(__dirname, 'public'))) - -// cache -app.use(cache('2 minutes', (req, res) => res.statusCode === 200)) -// router -const special = { - 'daily_signin.js': '/daily_signin', - 'fm_trash.js': '/fm_trash', - 'personal_fm.js': '/personal_fm', -} - -fs.readdirSync(path.join(__dirname, 'module')) - .reverse() - .forEach((file) => { - if (!file.endsWith('.js')) return - let route = - file in special - ? special[file] - : '/' + file.replace(/\.js$/i, '').replace(/_/g, '/') - let question = require(path.join(__dirname, 'module', file)) - - app.use(route, (req, res) => { - ;[req.query, req.body].forEach((item) => { - if (typeof item.cookie === 'string') { - item.cookie = cookieToJson(decode(item.cookie)) - } - }) - let query = Object.assign( - {}, - { cookie: req.cookies }, - req.query, - req.body, - req.files, - ) - - question(query, request) - .then((answer) => { - console.log('[OK]', decode(req.originalUrl)) - - const cookies = answer.cookie - if (Array.isArray(cookies) && cookies.length > 0) { - if (req.protocol === 'https') { - // Try to fix CORS SameSite Problem - res.append( - 'Set-Cookie', - cookies.map((cookie) => { - return cookie + '; SameSite=None; Secure' - }), - ) - } else { - res.append('Set-Cookie', cookies) - } - } - res.status(answer.status).send(answer.body) - }) - .catch((answer) => { - console.log('[ERR]', decode(req.originalUrl), { - status: answer.status, - body: answer.body, - }) - if (!answer.body) { - res.status(404).send({ - code: 404, - data: null, - msg: 'Not Found', - }) - return - } - if (answer.body.code == '301') answer.body.msg = '需要登录' - res.append('Set-Cookie', answer.cookie) - res.status(answer.status).send(answer.body) - }) - }) - }) - -const port = process.env.PORT || 3000 -const host = process.env.HOST || '' - -app.server = app.listen(port, host, () => { - console.log(`server running @ http://${host ? host : 'localhost'}:${port}`) -}) - -module.exports = app diff --git a/app.test.js b/app.test.js deleted file mode 100644 index 94846b2..0000000 --- a/app.test.js +++ /dev/null @@ -1,15 +0,0 @@ -const fs = require('fs') -const path = require('path') - -let app -before(() => { - app = require('./app.js') - global.host = 'http://localhost:' + app.server.address().port -}) -after((done) => { - app.server.close(done) -}) - -fs.readdirSync(path.join(__dirname, 'test')).forEach((file) => { - require(path.join(__dirname, 'test', file)) -}) diff --git a/examples/get_static_moddef.js b/examples/get_static_moddef.js new file mode 100644 index 0000000..f430aed --- /dev/null +++ b/examples/get_static_moddef.js @@ -0,0 +1,22 @@ +const fsPromises = require('fs/promises') +const path = require('path') +const server = require('../server') + +const exportFile = path.join(__dirname, 'moddef.json') + +async function main() { + const def = await server.getModulesDefinitions( + path.join(__dirname, '..', 'module'), + { + 'daily_signin.js': '/daily_signin', + 'fm_trash.js': '/fm_trash', + 'personal_fm.js': '/personal_fm', + }, + false, + ) + + fsPromises.writeFile(exportFile, JSON.stringify(def, null, 4)) + console.log(`👍 Get your own definition at: ${exportFile}`) +} + +main() diff --git a/main.js b/main.js deleted file mode 100644 index 0b21490..0000000 --- a/main.js +++ /dev/null @@ -1,26 +0,0 @@ -const fs = require('fs') -const path = require('path') -const request = require('./util/request') -const { cookieToJson } = require('./util/index') - -let obj = {} -fs.readdirSync(path.join(__dirname, 'module')) - .reverse() - .forEach((file) => { - if (!file.endsWith('.js')) return - let fileModule = require(path.join(__dirname, 'module', file)) - obj[file.split('.').shift()] = function (data) { - if (typeof data.cookie === 'string') { - data.cookie = cookieToJson(data.cookie) - } - return fileModule( - { - ...data, - cookie: data.cookie ? data.cookie : {}, - }, - request, - ) - } - }) - -module.exports = obj diff --git a/package-lock.json b/package-lock.json index 09bb2b6..b90d27d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "NeteaseCloudMusicApi", - "version": "4.2.0", + "version": "4.3.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "NeteaseCloudMusicApi", - "version": "4.2.0", + "version": "4.3.0", "license": "MIT", "dependencies": { "axios": "^0.24.0", @@ -24,6 +24,9 @@ "NeteaseCloudMusicApi": "app.js" }, "devDependencies": { + "@types/express": "^4.17.13", + "@types/express-fileupload": "^1.2.2", + "@types/mocha": "^9.1.0", "@types/node": "16.11.19", "@typescript-eslint/eslint-plugin": "5.0.0", "@typescript-eslint/parser": "5.0.0", @@ -186,22 +189,107 @@ "node": ">= 6" } }, + "node_modules/@types/body-parser": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", + "integrity": "sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==", + "dev": true, + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/busboy": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@types/busboy/-/busboy-0.3.2.tgz", + "integrity": "sha512-iEvdm9Z9KdSs/ozuh1Z7ZsXrOl8F4M/CLMXPZHr3QuJ4d6Bjn+HBMC5EMKpwpAo8oi8iK9GZfFoHaIMrrZgwVw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.35", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", + "integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.5.tgz", "integrity": "sha512-Q1y515GcOdTHgagaVFhHnIFQ38ygs/kmxdNpvpou+raI9UO3YZcHDngBSYKQklcKlvA7iuQlmIKbzvmxcOE9CQ==" }, + "node_modules/@types/express": { + "version": "4.17.13", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz", + "integrity": "sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA==", + "dev": true, + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.18", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-fileupload": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@types/express-fileupload/-/express-fileupload-1.2.2.tgz", + "integrity": "sha512-sWU1EVFfLsdAginKVrkwTRbRPnbn7dawxEFEBgaRDcpNFCUuksZtASaAKEhqwEIg6fSdeTyI6dIUGl3thhrypg==", + "dev": true, + "dependencies": { + "@types/busboy": "^0", + "@types/express": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.17.28", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.28.tgz", + "integrity": "sha512-P1BJAEAW3E2DJUlkgq4tOL3RyMunoWXqbSCygWo5ZIWTjUgN1YnaXWW4VWl/oc8vs/XoYibEGBKP0uZyF4AHig==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.9", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.9.tgz", "integrity": "sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==", "dev": true }, + "node_modules/@types/mime": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", + "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==", + "dev": true + }, + "node_modules/@types/mocha": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-9.1.0.tgz", + "integrity": "sha512-QCWHkbMv4Y5U9oW10Uxbr45qMMSzl4OzijsozynUAgx3kEHUdXB00udx2dWDQ7f2TU2a2uuiFaRZjCe3unPpeg==", + "dev": true + }, "node_modules/@types/node": { "version": "16.11.19", "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.19.tgz", "integrity": "sha512-BPAcfDPoHlRQNKktbsbnpACGdypPFBuX4xQlsWDE7B8XXcfII+SpOLay3/qZmCLb39kV5S1RTYwXdkx2lwLYng==" }, + "node_modules/@types/qs": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", + "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==", + "dev": true + }, + "node_modules/@types/range-parser": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz", + "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==", + "dev": true + }, "node_modules/@types/readable-stream": { "version": "2.3.9", "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-2.3.9.tgz", @@ -211,6 +299,16 @@ "safe-buffer": "*" } }, + "node_modules/@types/serve-static": { + "version": "1.13.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.10.tgz", + "integrity": "sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ==", + "dev": true, + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.0.0.tgz", @@ -1502,6 +1600,8 @@ "resolved": "https://registry.npm.taobao.org/enquirer/download/enquirer-2.3.6.tgz?cache=0&sync_timestamp=1593693291943&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fenquirer%2Fdownload%2Fenquirer-2.3.6.tgz", "integrity": "sha1-Kn/l3WNKHkElqXXsmU/1RW3Dc00=", "dev": true, + "optional": true, + "peer": true, "dependencies": { "ansi-colors": "^4.1.1" }, @@ -5770,22 +5870,107 @@ "resolved": "https://registry.npm.taobao.org/@tootallnate/once/download/@tootallnate/once-1.1.2.tgz", "integrity": "sha1-zLkURTYBeaBOf+av94wA/8Hur4I=" }, + "@types/body-parser": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", + "integrity": "sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==", + "dev": true, + "requires": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "@types/busboy": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@types/busboy/-/busboy-0.3.2.tgz", + "integrity": "sha512-iEvdm9Z9KdSs/ozuh1Z7ZsXrOl8F4M/CLMXPZHr3QuJ4d6Bjn+HBMC5EMKpwpAo8oi8iK9GZfFoHaIMrrZgwVw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/connect": { + "version": "3.4.35", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", + "integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/debug": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.5.tgz", "integrity": "sha512-Q1y515GcOdTHgagaVFhHnIFQ38ygs/kmxdNpvpou+raI9UO3YZcHDngBSYKQklcKlvA7iuQlmIKbzvmxcOE9CQ==" }, + "@types/express": { + "version": "4.17.13", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz", + "integrity": "sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA==", + "dev": true, + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.18", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "@types/express-fileupload": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@types/express-fileupload/-/express-fileupload-1.2.2.tgz", + "integrity": "sha512-sWU1EVFfLsdAginKVrkwTRbRPnbn7dawxEFEBgaRDcpNFCUuksZtASaAKEhqwEIg6fSdeTyI6dIUGl3thhrypg==", + "dev": true, + "requires": { + "@types/busboy": "^0", + "@types/express": "*" + } + }, + "@types/express-serve-static-core": { + "version": "4.17.28", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.28.tgz", + "integrity": "sha512-P1BJAEAW3E2DJUlkgq4tOL3RyMunoWXqbSCygWo5ZIWTjUgN1YnaXWW4VWl/oc8vs/XoYibEGBKP0uZyF4AHig==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*" + } + }, "@types/json-schema": { "version": "7.0.9", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.9.tgz", "integrity": "sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==", "dev": true }, + "@types/mime": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", + "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==", + "dev": true + }, + "@types/mocha": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-9.1.0.tgz", + "integrity": "sha512-QCWHkbMv4Y5U9oW10Uxbr45qMMSzl4OzijsozynUAgx3kEHUdXB00udx2dWDQ7f2TU2a2uuiFaRZjCe3unPpeg==", + "dev": true + }, "@types/node": { "version": "16.11.19", "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.19.tgz", "integrity": "sha512-BPAcfDPoHlRQNKktbsbnpACGdypPFBuX4xQlsWDE7B8XXcfII+SpOLay3/qZmCLb39kV5S1RTYwXdkx2lwLYng==" }, + "@types/qs": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", + "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==", + "dev": true + }, + "@types/range-parser": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz", + "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==", + "dev": true + }, "@types/readable-stream": { "version": "2.3.9", "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-2.3.9.tgz", @@ -5795,6 +5980,16 @@ "safe-buffer": "*" } }, + "@types/serve-static": { + "version": "1.13.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.10.tgz", + "integrity": "sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ==", + "dev": true, + "requires": { + "@types/mime": "^1", + "@types/node": "*" + } + }, "@typescript-eslint/eslint-plugin": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.0.0.tgz", @@ -6743,6 +6938,8 @@ "resolved": "https://registry.npm.taobao.org/enquirer/download/enquirer-2.3.6.tgz?cache=0&sync_timestamp=1593693291943&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fenquirer%2Fdownload%2Fenquirer-2.3.6.tgz", "integrity": "sha1-Kn/l3WNKHkElqXXsmU/1RW3Dc00=", "dev": true, + "optional": true, + "peer": true, "requires": { "ansi-colors": "^4.1.1" } diff --git a/package.json b/package.json index bd88d0b..0fde53f 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "网易云音乐 NodeJS 版 API", "scripts": { "start": "node app.js", - "test": "mocha -r intelli-espower-loader -t 20000 app.test.js --exit", + "test": "mocha -r intelli-espower-loader -t 20000 server.test.js --exit", "lint": "eslint \"**/*.{js,ts}\"", "lint-fix": "eslint --fix \"**/*.{js,ts}\"", "prepare": "husky install", @@ -22,7 +22,7 @@ "音乐", "网易云音乐nodejs" ], - "main": "main.js", + "main": "server.js", "types": "./interface.d.ts", "engines": { "node": ">=12" @@ -56,6 +56,9 @@ "yargs": "^17.1.1" }, "devDependencies": { + "@types/express": "^4.17.13", + "@types/express-fileupload": "^1.2.2", + "@types/mocha": "^9.1.0", "@types/node": "16.11.19", "@typescript-eslint/eslint-plugin": "5.0.0", "@typescript-eslint/parser": "5.0.0", diff --git a/server.js b/server.js new file mode 100644 index 0000000..4d8e4b0 --- /dev/null +++ b/server.js @@ -0,0 +1,300 @@ +const fs = require('fs') +const path = require('path') +const express = require('express') +const request = require('./util/request') +const packageJSON = require('./package.json') +const exec = require('child_process').exec +const cache = require('./util/apicache').middleware +const { cookieToJson } = require('./util/index') +const fileUpload = require('express-fileupload') +const decode = require('safe-decode-uri-component') + +/** + * The version check result. + * @readonly + * @enum {number} + */ +const VERSION_CHECK_RESULT = { + FAILED: -1, + NOT_LATEST: 0, + LATEST: 1, +} + +/** + * @typedef {{ + * identifier?: string, + * route: string, + * module: any + * }} ModuleDefinition + */ + +/** + * @typedef {{ + * port?: number, + * host?: string, + * checkVersion?: boolean, + * moduleDefs?: ModuleDefinition[] + * }} NcmApiOptions + */ + +/** + * @typedef {{ + * status: VERSION_CHECK_RESULT, + * ourVersion?: string, + * npmVersion?: string, + * }} VersionCheckResult + */ + +/** + * @typedef {{ + * server?: import('http').Server, + * }} ExpressExtension + */ + +/** + * Get the module definitions dynamically. + * + * @param {string} modulesPath The path to modules (JS). + * @param {Record} [specificRoute] The specific route of specific modules. + * @param {boolean} [doRequire] If true, require() the module directly. + * Otherwise, print out the module path. Default to true. + * @returns {Promise} The module definitions. + * + * @example getModuleDefinitions("./module", {"album_new.js": "/album/create"}) + */ +async function getModulesDefinitions( + modulesPath, + specificRoute, + doRequire = true, +) { + const files = await fs.promises.readdir(modulesPath) + const parseRoute = (/** @type {string} */ fileName) => + specificRoute && fileName in specificRoute + ? specificRoute[fileName] + : `/${fileName.replace(/\.js$/i, '').replace(/_/g, '/')}` + + const modules = files + .reverse() + .filter((file) => file.endsWith('.js')) + .map((file) => { + const identifier = file.split('.').shift() + const route = parseRoute(file) + const modulePath = path.join(modulesPath, file) + const module = doRequire ? require(modulePath) : modulePath + + return { identifier, route, module } + }) + + return modules +} + +/** + * Check if the version of this API is latest. + * + * @returns {Promise} If true, this API is up-to-date; + * otherwise, this API should be upgraded and you would + * need to notify users to upgrade it manually. + */ +async function checkVersion() { + return new Promise((resolve) => { + exec('npm info NeteaseCloudMusicApi version', (err, stdout) => { + if (!err) { + let version = stdout.trim() + + /** + * @param {VERSION_CHECK_RESULT} status + */ + const resolveStatus = (status) => + resolve({ + status, + ourVersion: packageJSON.version, + npmVersion: version, + }) + + resolveStatus( + packageJSON.version < version + ? VERSION_CHECK_RESULT.NOT_LATEST + : VERSION_CHECK_RESULT.LATEST, + ) + } + }) + + resolve({ + status: VERSION_CHECK_RESULT.FAILED, + }) + }) +} + +/** + * Construct the server of NCM API. + * + * @param {ModuleDefinition[]} [moduleDefs] Customized module definitions [advanced] + * @returns {Promise} The server instance. + */ +async function consturctServer(moduleDefs) { + const app = express() + app.set('trust proxy', true) + + /** + * CORS & Preflight request + */ + app.use((req, res, next) => { + if (req.path !== '/' && !req.path.includes('.')) { + res.set({ + 'Access-Control-Allow-Credentials': true, + 'Access-Control-Allow-Origin': req.headers.origin || '*', + 'Access-Control-Allow-Headers': 'X-Requested-With,Content-Type', + 'Access-Control-Allow-Methods': 'PUT,POST,GET,DELETE,OPTIONS', + 'Content-Type': 'application/json; charset=utf-8', + }) + } + req.method === 'OPTIONS' ? res.status(204).end() : next() + }) + + /** + * Cookie Parser + */ + app.use((req, _, next) => { + req.cookies = {} + //;(req.headers.cookie || '').split(/\s*;\s*/).forEach((pair) => { // Polynomial regular expression // + ;(req.headers.cookie || '').split(/;\s+|(? { + let crack = pair.indexOf('=') + if (crack < 1 || crack == pair.length - 1) return + req.cookies[decode(pair.slice(0, crack)).trim()] = decode( + pair.slice(crack + 1), + ).trim() + }) + next() + }) + + /** + * Body Parser and File Upload + */ + app.use(express.json()) + app.use(express.urlencoded({ extended: false })) + + app.use(fileUpload()) + + /** + * Serving static files + */ + app.use(express.static(path.join(__dirname, 'public'))) + + /** + * Cache + */ + app.use(cache('2 minutes', (_, res) => res.statusCode === 200)) + + /** + * Special Routers + */ + const special = { + 'daily_signin.js': '/daily_signin', + 'fm_trash.js': '/fm_trash', + 'personal_fm.js': '/personal_fm', + } + + /** + * Load every modules in this directory + */ + const moduleDefinitions = + moduleDefs || + (await getModulesDefinitions(path.join(__dirname, 'module'), special)) + + for (const moduleDef of moduleDefinitions) { + // Register the route. + app.use(moduleDef.route, async (req, res) => { + ;[req.query, req.body].forEach((item) => { + if (typeof item.cookie === 'string') { + item.cookie = cookieToJson(decode(item.cookie)) + } + }) + + let query = Object.assign( + {}, + { cookie: req.cookies }, + req.query, + req.body, + ) + + try { + const moduleResponse = await moduleDef.module(query, request) + console.log('[OK]', decode(req.originalUrl)) + + const cookies = moduleResponse.cookie + if (Array.isArray(cookies) && cookies.length > 0) { + if (req.protocol === 'https') { + // Try to fix CORS SameSite Problem + res.append( + 'Set-Cookie', + cookies.map((cookie) => { + return cookie + '; SameSite=None; Secure' + }), + ) + } else { + res.append('Set-Cookie', cookies) + } + } + res.status(moduleResponse.status).send(moduleResponse.body) + } catch (/** @type {*} */ moduleResponse) { + console.log('[ERR]', decode(req.originalUrl), { + status: moduleResponse.status, + body: moduleResponse.body, + }) + if (!moduleResponse.body) { + res.status(404).send({ + code: 404, + data: null, + msg: 'Not Found', + }) + return + } + if (moduleResponse.body.code == '301') + moduleResponse.body.msg = '需要登录' + res.append('Set-Cookie', moduleResponse.cookie) + res.status(moduleResponse.status).send(moduleResponse.body) + } + }) + } + + return app +} + +/** + * Serve the NCM API. + * @param {NcmApiOptions} options + * @returns {Promise} + */ +async function serveNcmApi(options) { + const port = Number(options.port || process.env.PORT || '3000') + const host = options.host || process.env.HOST || '' + + const checkVersionSubmission = + options.checkVersion && + checkVersion().then(({ npmVersion, ourVersion, status }) => { + if (status == VERSION_CHECK_RESULT.NOT_LATEST) { + console.log( + `最新版本: ${npmVersion}, 当前版本: ${ourVersion}, 请及时更新`, + ) + } + }) + const constructServerSubmission = consturctServer(options.moduleDefs) + + const [_, app] = await Promise.all([ + checkVersionSubmission, + constructServerSubmission, + ]) + + /** @type {import('express').Express & ExpressExtension} */ + const appExt = app + appExt.server = app.listen(port, host, () => { + console.log(`server running @ http://${host ? host : 'localhost'}:${port}`) + }) + + return appExt +} + +module.exports = { + serveNcmApi, + getModulesDefinitions, +} diff --git a/server.test.js b/server.test.js new file mode 100644 index 0000000..e3d28b2 --- /dev/null +++ b/server.test.js @@ -0,0 +1,33 @@ +const fs = require('fs') +const path = require('path') +const serverMod = require('./server') + +/** @type {import("express").Express & serverMod.ExpressExtension} */ +let app + +before(async () => { + app = await serverMod.serveNcmApi({}) + + if (app.server && app.server.address) { + const addr = app.server.address() + if (addr && typeof addr === 'object' && 'port' in addr) { + global.host = `http://localhost:${addr.port}` + return + } + } + + throw new Error('failed to set up host') +}) + +after((done) => { + if (app.server) { + app.server.close(done) + return + } + + throw new Error('failed to set up server') +}) + +fs.readdirSync(path.join(__dirname, 'test')).forEach((file) => { + require(path.join(__dirname, 'test', file)) +})