上一篇文章这个高颜值的开源第三方网易云音乐播放器你值得领有介绍了一个开源的第三方网易云音乐播放器,这篇文章咱们来具体理解一下其中应用到的网易云音乐api我的项目NeteaseCloudMusicApi的实现原理。

NeteaseCloudMusicApi应用Node.js开发,次要用到的框架和库有两个,一个Web利用开发框架Express,一个申请库Axios,这两个大家应该都很熟了就不过多介绍了。

创立express利用

我的项目的入口文件为/app.js

async function start() {  require('./server').serveNcmApi({    checkVersion: true,  })}start()

调用了/server.js文件的serveNcmApi办法,让咱们转到这个文件,serveNcmApi办法简化后如下:

async function serveNcmApi(options) {    const port = Number(options.port || process.env.PORT || '3000')    const host = options.host || process.env.HOST || ''    const app = await consturctServer(options.moduleDefs)    const appExt = app    appExt.server = app.listen(port, host, () => {        console.log(`server running @ http://${host ? host : 'localhost'}:${port}`)    })    return appExt}

次要是启动监听指定端口,所以创立利用的次要逻辑在consturctServer办法:

async function consturctServer(moduleDefs) {    // 创立一个利用    const app = express()    // 设置为true,则客户端的IP地址被了解为X-Forwarded-*报头中最右边的条目    app.set('trust proxy', true)    /**   * 配置CORS & 预检申请   */    app.use((req, res, next) => {        if (req.path !== '/' && !req.path.includes('.')) {            res.set({                'Access-Control-Allow-Credentials': true, // 跨域状况下,容许客户端携带验证信息,比方cookie,同时,前端发送申请时也须要设置withCredentials: true                'Access-Control-Allow-Origin': req.headers.origin || '*', // 容许跨域申请的域名,设置为*代表容许所有域名                'Access-Control-Allow-Headers': 'X-Requested-With,Content-Type', // 用于给预检申请(options)列出服务端容许的自定义标头,如果前端发送的申请中蕴含自定义的申请标头,且该标头不蕴含在Access-Control-Allow-Headers中,那么该申请无奈胜利发动                'Access-Control-Allow-Methods': 'PUT,POST,GET,DELETE,OPTIONS', // 设置跨域申请容许的申请办法现实                'Content-Type': 'application/json; charset=utf-8', // 设置响应数据的类型及编码            })        }        // OPTIONS为预检申请,简单申请会在发送真正的申请前先发送一个预检申请,获取服务器反对的Access-Control-Allow-xxx相干信息,判断后续是否有必要再发送真正的申请,返回状态码204代表申请胜利,然而没有内容        req.method === 'OPTIONS' ? res.status(204).end() : next()    })    // ...}

首先创立了一个Express利用,而后设置为信赖代理,在Express里获取ip个别是通过req.ipreq.ipstrust proxy默认值为false,这种状况下req.ips值是空的,当设置为true时,req.ip的值会从申请头X-Forwarded-For上取最左侧的一个值,req.ips则会蕴含X-Forwarded-For头部的所有ip地址。

X-Forwarded-For头部的格局如下:

X-Forwarded-For: client1, proxy1, proxy2

值通过一个 逗号+空格 把多个ip地址辨别开,最右边的client1是最原始客户端的ip地址,代理服务器每胜利收到一个申请,就把申请起源ip地址增加到左边。

以下面为例,这个申请通过了两台代理服务器:proxy1proxy2。申请由client1收回,此时XFF是空的,到了proxy1时,proxy1client1增加到XFF中,之后申请发往proxy2,通过proxy2的时候,proxy1被增加到XFF中,之后申请发往最终服务器,达到后proxy2被增加到XFF中。

然而伪造这个字段非常容易,所以当代理不可信时,这个字段也不肯定牢靠,不过失常状况下XFF中最初一个ip地址必定是最初一个代理服务器的ip地址,这个会比拟牢靠。

随后设置了跨域响应头,这里的设置就是容许不同域名的网站也能申请胜利的关键所在。

持续:

async function consturctServer(moduleDefs) {    // ...    /**   * 解析Cookie   */    app.use((req, _, next) => {        req.cookies = {}        //;(req.headers.cookie || '').split(/\s*;\s*/).forEach((pair) => { //  Polynomial regular expression //        // 从申请头中读取cookie,cookie格局为:name=value;name2=value2...,所以先依据;切割为数组        ;(req.headers.cookie || '').split(/;\s+|(?<!\s)\s+$/g).forEach((pair) => {            let crack = pair.indexOf('=')            // 没有值的间接跳过            if (crack < 1 || crack == pair.length - 1) return            // 将cookie保留到cookies对象上            req.cookies[decode(pair.slice(0, crack)).trim()] = decode(                pair.slice(crack + 1),            ).trim()        })        next()    })    /**   * 申请体解析和文件上传解决   */    app.use(express.json())    app.use(express.urlencoded({ extended: false }))    app.use(fileUpload())    /**   * 将public目录下的文件作为动态文件提供   */    app.use(express.static(path.join(__dirname, 'public')))    /**   * 缓存申请,两分钟内同样的申请会从缓存里读取数据,不会向网易云音乐服务器发送申请   */    app.use(cache('2 minutes', (_, res) => res.statusCode === 200))    // ...}

接下来注册了一些中间件,用来解析cookie、解决申请体等,另外还做了接口缓存,避免太频繁申请网易云音乐服务器导致被封掉。

持续:

async function consturctServer(moduleDefs) {    // ...    /**   * 非凡路由   */    const special = {        'daily_signin.js': '/daily_signin',        'fm_trash.js': '/fm_trash',        'personal_fm.js': '/personal_fm',    }    /**   * 加载/module目录下的所有模块,每个模块对应一个接口   */    const moduleDefinitions =          moduleDefs ||          (await getModulesDefinitions(path.join(__dirname, 'module'), special))    // ...}

接下来加载了/module目录下所有的模块:

每个模块代表一个对网易云音乐接口的申请,比方获取专辑详情的album_detail.js

模块加载办法getModulesDefinitions如下:

async function getModulesDefinitions(  modulesPath,  specificRoute,  doRequire = true,) {  const files = await fs.promises.readdir(modulesPath)  const parseRoute = (fileName) =>    specificRoute && fileName in specificRoute      ? specificRoute[fileName]      : `/${fileName.replace(/\.js$/i, '').replace(/_/g, '/')}`  // 遍历目录下的所有文件  const modules = files    .reverse()    .filter((file) => file.endsWith('.js'))// 过滤出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}

以方才的album_detail.js模块为例,返回的数据如下:

{     identifier: 'album_detail',     route: '/album/detail',     module: () => {/*模块内容*/}}

接下来就是注册路由:

async function consturctServer(moduleDefs) {     // ...    for (const moduleDef of moduleDefinitions) {        // 注册路由        app.use(moduleDef.route, async (req, res) => {            // cookie也能够从查问参数、申请体上传来            ;[req.query, req.body].forEach((item) => {                if (typeof item.cookie === 'string') {                    // 将cookie字符串转换成json类型                    item.cookie = cookieToJson(decode(item.cookie))                }            })            // 把cookie、查问参数、申请头、文件都整合到一起,作为参数传给每个模块            let query = Object.assign(                {},                { cookie: req.cookies },                req.query,                req.body,                req.files,            )            try {                // 执行模块办法,即发动对网易云音乐接口的申请                const moduleResponse = await moduleDef.module(query, (...params) => {                    // 参数注入客户端IP                    const obj = [...params]                    // 解决ip,为了实现IPv4-IPv6互通,IPv4地址前会减少::ffff:                    let ip = req.ip                    if (ip.substr(0, 7) == '::ffff:') {                        ip = ip.substr(7)                    }                    obj[3] = {                        ...obj[3],                        ip,                    }                    return request(...obj)                })                // 申请胜利后,获取响应中的cookie,并且通过Set-Cookie响应头来将这个cookie设置到前端浏览器上                const cookies = moduleResponse.cookie                if (Array.isArray(cookies) && cookies.length > 0) {                    if (req.protocol === 'https') {                        // 去掉跨域申请cookie的SameSite限度,这个属性用来限度第三方Cookie,从而缩小平安危险                        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 (moduleResponse) {                // 申请失败解决                // 没有响应体,返回404                if (!moduleResponse.body) {                    res.status(404).send({                        code: 404,                        data: null,                        msg: 'Not Found',                    })                    return                }                // 301代表调用了须要登录的接口,然而并没有登录                if (moduleResponse.body.code == '301')                    moduleResponse.body.msg = '须要登录'                res.append('Set-Cookie', moduleResponse.cookie)                res.status(moduleResponse.status).send(moduleResponse.body)            }        })    }    return app}

逻辑很清晰,将每个模块都注册成一个路由,接管到对应的申请后,将cookie、查问参数、申请体等都传给对应的模块,而后申请网易云音乐的接口,如果申请胜利了,那么解决一下网易云音乐接口返回的cookie,最初将数据都返回给前端即可,如果接口失败了,那么也进行对应的解决。

其中从申请的查问参数和申请体里获取cookie可能不是很好了解,因为cookie个别是从申请体里带过去,这么做应该次要是为了反对在Node.js里调用:

申请胜利后,返回的数据里如果存在cookie,那么会进行一些解决,首先如果是https的申请,那么会设置SameSite=None; SecureSameSiteCookie中的一个属性,用来限度第三方Cookie,从而缩小平安危险。Chrome 51 开始新增这个属性,用来避免CSRF攻打和用户追踪,有三个可选值:strict/lax/none,默认为lax,比方在域名为https://123.com的页面里调用https://456.com域名的接口,默认状况下除了导航到123网址的get申请除外,其余申请都不会携带123域名的cookie,如果设置为strict更严格,齐全不会携带cookie,所以这个我的项目为了不便跨域调用,设置为none,不进行限度,设置为none的同时须要设置Secure属性。

最初通过Set-Cookie响应头将cookie写入前端的浏览器即可。

发送申请

接下来看一下下面波及到发送申请所应用的request办法,这个办法在/util/request.js文件,首先引入了一些模块:

const encrypt = require('./crypto')const axios = require('axios')const PacProxyAgent = require('pac-proxy-agent')const http = require('http')const https = require('https')const tunnel = require('tunnel')const { URLSearchParams, URL } = require('url')const config = require('../util/config.json')// ...

而后就是具体发送申请的办法createRequest,这个办法也挺长的,咱们缓缓来看:

const createRequest = (method, url, data = {}, options) => {    return new Promise((resolve, reject) => {        let headers = { 'User-Agent': chooseUserAgent(options.ua) }        // ...        })}

函数会返回一个Promise,首先定义了一个申请头对象,并增加了User-Agent头,这个头部会保留浏览器类型、版本号、渲染引擎,以及操作系统、版本、CPU类型等信息,规范格局为:

浏览器标识 (操作系统标识; 加密等级标识; 浏览器语言) 渲染引擎标识 版本信息

不必多说,伪造这个头显然是用来坑骗服务器,让它认为这个申请是来自浏览器,而不是同样也来自服务端。

默认写死了几个User-Agent头部随机进行抉择:

const chooseUserAgent = (ua = false) => {    const userAgentList = {        mobile: [            'Mozilla/5.0 (iPhone; CPU iPhone OS 13_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Mobile/15E148 Safari/604.1',            'Mozilla/5.0 (Linux; Android 9; PCT-AL10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.64 HuaweiBrowser/10.0.3.311 Mobile Safari/537.36',            // ...        ],        pc: [            'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:80.0) Gecko/20100101 Firefox/80.0',            'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:80.0) Gecko/20100101 Firefox/80.0',            // ...        ],    }    let realUserAgentList =        userAgentList[ua] || userAgentList.mobile.concat(userAgentList.pc)    return ['mobile', 'pc', false].indexOf(ua) > -1        ? realUserAgentList[Math.floor(Math.random() * realUserAgentList.length)]    : ua}

持续看:

const createRequest = (method, url, data = {}, options) => {    return new Promise((resolve, reject) => {        // ...        // 如果是post申请,批改编码格局        if (method.toUpperCase() === 'POST')            headers['Content-Type'] = 'application/x-www-form-urlencoded'        // 伪造Referer头        if (url.includes('music.163.com'))            headers['Referer'] = 'https://music.163.com'        // 设置ip头部        let ip = options.realIP || options.ip || ''        if (ip) {            headers['X-Real-IP'] = ip            headers['X-Forwarded-For'] = ip        }        // ...    })}

持续设置了几个头部字段,Axios默认的编码格局为json,而POST申请个别都会应用application/x-www-form-urlencoded编码格局。

Referer头代表发送申请时所在页面的url,比方在https://123.com页面内调用https://456.com接口,Referer头会设置为https://123.com,这个头部个别用来防盗链。所以伪造这个头部也是为了坑骗服务器这个申请是来自它们本人的页面。

接下来设置了两个ip头部,realIP须要前端手动传递:

持续:

const createRequest = (method, url, data = {}, options) => {    return new Promise((resolve, reject) => {        // ...        // 设置cookie        if (typeof options.cookie === 'object') {            if (!options.cookie.MUSIC_U) {                // 游客                if (!options.cookie.MUSIC_A) {                    options.cookie.MUSIC_A = config.anonymous_token                }            }            headers['Cookie'] = Object.keys(options.cookie)                .map(                (key) =>                encodeURIComponent(key) +                '=' +                encodeURIComponent(options.cookie[key]),            )                .join('; ')        } else if (options.cookie) {            headers['Cookie'] = options.cookie        }        // ...    })}

接下来设置cookie,分两种类型,一种是对象类型,这种状况cookie个别来源于查问参数或者申请体,另一种为字符串,这个就是失常状况下申请头带过去的。MUSIC_U应该就是登录后的cookie了,MUSIC_A应该是一个token,未登录状况下调用某些接口可能报错,所以会设置一个游客token

持续:

const createRequest = (method, url, data = {}, options) => {    return new Promise((resolve, reject) => {        // ...        if (options.crypto === 'weapi') {            let csrfToken = (headers['Cookie'] || '').match(/_csrf=([^(;|$)]+)/)            data.csrf_token = csrfToken ? csrfToken[1] : ''            data = encrypt.weapi(data)            url = url.replace(/\w*api/, 'weapi')        } else if (options.crypto === 'linuxapi') {            data = encrypt.linuxapi({                method: method,                url: url.replace(/\w*api/, 'api'),                params: data,            })            headers['User-Agent'] =                'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36'            url = 'https://music.163.com/api/linux/forward'        } else if (options.crypto === 'eapi') {            const cookie = options.cookie || {}            const csrfToken = cookie['__csrf'] || ''            const header = {                osver: cookie.osver, //零碎版本                deviceId: cookie.deviceId, //encrypt.base64.encode(imei + '\t02:00:00:00:00:00\t5106025eb79a5247\t70ffbaac7')                appver: cookie.appver || '8.7.01', // app版本                versioncode: cookie.versioncode || '140', //版本号                mobilename: cookie.mobilename, //设施model                buildver: cookie.buildver || Date.now().toString().substr(0, 10),                resolution: cookie.resolution || '1920x1080', //设施分辨率                __csrf: csrfToken,                os: cookie.os || 'android',                channel: cookie.channel,                requestId: `${Date.now()}_${Math.floor(Math.random() * 1000)                .toString()                .padStart(4, '0')}`,            }            if (cookie.MUSIC_U) header['MUSIC_U'] = cookie.MUSIC_U            if (cookie.MUSIC_A) header['MUSIC_A'] = cookie.MUSIC_A            headers['Cookie'] = Object.keys(header)                .map(                (key) =>                encodeURIComponent(key) + '=' + encodeURIComponent(header[key]),            )                .join('; ')            data.header = header            data = encrypt.eapi(options.url, data)            url = url.replace(/\w*api/, 'eapi')        }        // ...    })}

这一段代码会比拟难了解,笔者也没有看懂,反正大抵呢这个我的项目应用了四种类型网易云音乐的接口:weapilinuxapieapiapi,比方:

https://music.163.com/weapi/vipmall/albumproduct/detailhttps://music.163.com/eapi/activate/initProfilehttps://music.163.com/api/album/detail/dynamic

每种类型的接口申请参数、加密形式都不一样,所以须要离开独自解决:

比方weapi

let csrfToken = (headers['Cookie'] || '').match(/_csrf=([^(;|$)]+)/)data.csrf_token = csrfToken ? csrfToken[1] : ''data = encrypt.weapi(data)url = url.replace(/\w*api/, 'weapi')

cookie中的_csrf值取出加到申请数据中,而后加密数据:

const weapi = (object) => {  const text = JSON.stringify(object)  const secretKey = crypto    .randomBytes(16)    .map((n) => base62.charAt(n % 62).charCodeAt())  return {    params: aesEncrypt(      Buffer.from(        aesEncrypt(Buffer.from(text), 'cbc', presetKey, iv).toString('base64'),      ),      'cbc',      secretKey,      iv,    ).toString('base64'),    encSecKey: rsaEncrypt(secretKey.reverse(), publicKey).toString('hex'),  }}

查看其余加密算法:crypto.js。

至于这些是怎么晓得的呢,要么就是网易云音乐外部人士(根本不可能),要么就是进行逆向了,比方网页版的接口,关上控制台,发送申请,找到在源码中的地位, 打断点,查看申请数据结构,浏览压缩或混同后的源码缓缓进行尝试,总之,向这些大佬致敬。

持续:

const createRequest = (method, url, data = {}, options) => {    return new Promise((resolve, reject) => {        // ...        // 响应的数据结构        const answer = { status: 500, body: {}, cookie: [] }        // 申请配置        let settings = {            method: method,            url: url,            headers: headers,            data: new URLSearchParams(data).toString(),            httpAgent: new http.Agent({ keepAlive: true }),            httpsAgent: new https.Agent({ keepAlive: true }),        }        if (options.crypto === 'eapi') settings.encoding = null        // 配置代理        if (options.proxy) {            if (options.proxy.indexOf('pac') > -1) {                settings.httpAgent = new PacProxyAgent(options.proxy)                settings.httpsAgent = new PacProxyAgent(options.proxy)            } else {                const purl = new URL(options.proxy)                if (purl.hostname) {                    const agent = tunnel.httpsOverHttp({                        proxy: {                            host: purl.hostname,                            port: purl.port || 80,                        },                    })                    settings.httpsAgent = agent                    settings.httpAgent = agent                    settings.proxy = false                } else {                    console.error('代理配置有效,不应用代理')                }            }        } else {            settings.proxy = false        }        if (options.crypto === 'eapi') {            settings = {                ...settings,                responseType: 'arraybuffer',            }        }        // ...    })}

这里次要是定义了响应的数据结构、定义了申请的配置数据,以及针对eapi做了一些非凡解决,最次要是代理的相干配置。

AgentNode.jsHTTP模块中的一个类,负责管理http客户端连贯的持久性和重用。 它保护一个给定主机和端口的待处理申请队列,为每个申请重用单个套接字连贯,直到队列为空,此时套接字要么被销毁,要么放入池中,在池里会被再次用于申请到雷同的主机和端口,总之就是省去了每次发动http申请时须要从新创立套接字的工夫,提高效率。

pac指代理主动配置,其实就是蕴含了一个javascript函数的文本文件,这个函数会决定是间接连贯还是通过某个代理连贯,比间接写死一个代理不便一点,当然须要配置的options.proxy是这个文件的近程地址,格局为:'pac+【pac文件地址】+'pac-proxy-agent模块会提供一个http.Agent实现,它会依据指定的PAC代理文件判断应用哪个HTTPHTTPSSOCKS代理,或者是间接连贯。

至于为什么要应用tunnel模块,笔者搜寻了一番还是没有搞懂,可能是解决http协定的接口申请网易云音乐的https协定接口失败的问题?晓得的敌人能够评论区解释一下~

最初:

const createRequest = (method, url, data = {}, options) => {    return new Promise((resolve, reject) => {        // ...        axios(settings)            .then((res) => {                const body = res.data                // 将响应的set-cookie头中的cookie取出,间接保留到响应对象上                answer.cookie = (res.headers['set-cookie'] || []).map((x) =>                    x.replace(/\s*Domain=[^(;|$)]+;*/, ''),// 去掉域名限度                )                try {                    // eapi返回的数据也是加密的,须要解密                    if (options.crypto === 'eapi') {                        answer.body = JSON.parse(encrypt.decrypt(body).toString())                    } else {                        answer.body = body                    }                    answer.status = answer.body.code || res.status                    // 对立这些状态码为200,都代表胜利                    if (                        [201, 302, 400, 502, 800, 801, 802, 803].indexOf(answer.body.code) > -1                    ) {                        // 非凡状态码                        answer.status = 200                    }                } catch (e) {                    try {                        answer.body = JSON.parse(body.toString())                    } catch (err) {                        answer.body = body                    }                    answer.status = res.status                }                answer.status =                    100 < answer.status && answer.status < 600 ? answer.status : 400                // 状态码200代表胜利,其余都代表失败                if (answer.status === 200) resolve(answer)                else reject(answer)            })            .catch((err) => {                answer.status = 502                answer.body = { code: 502, msg: err }                reject(answer)            })    })}

最初一步就是应用Axios发送申请了,解决了一下响应的cookie,保留到响应对象上,不便后续应用,另外解决了一些状态码,能够看到try-catch的应用比拟多,至于为什么呢,预计要多尝试来能晓得到底哪里会出错了,有趣味的能够自行尝试。

总结

本文通过源码角度理解了一下NeteaseCloudMusicApi我的项目的实现原理,能够看到整个流程是比较简单的。无非就是一个申请代理,难的在于找出这些接口,并且逆向剖析出每个接口的参数,加密办法,解密办法。最初也揭示一下,这个我的项目仅供学习应用,请勿从事商业行为或进行毁坏版权行为~