每個模塊代表一個對網易云音樂接口的請求,比如獲取專輯詳情的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; Secure,SameSiteCookie中的一個屬性,用來限制第三方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)<br>                .toString()<br>                .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')
        }
        // ...
    })
}

這一段代碼會比較難理解,筆者也沒有看懂,反正大致呢這個項目使用了四種類型網易云音樂的接口:weapi、linuxapieapi、api,比如:

https://music.163.com/weapi/vipmall/albumproduct/detail
https://music.163.com/eapi/activate/initProfile
https://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[5]

至于這些是怎么知道的呢,要么就是網易云音樂內部人士(基本不可能),要么就是進行逆向了,比如網頁版的接口,打開控制臺,發送請求,找到在源碼中的位置, 打斷點,查看請求數據結構,閱讀壓縮或混淆后的源碼慢慢進行嘗試,總之,向這些大佬致敬。

繼續:

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代理文件判斷使用哪個HTTP、HTTPS?或SOCKS代理,或者是直接連接。

至于為什么要使用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[6]項目的實現原理,可以看到整個流程是比較簡單的。無非就是一個請求代理,難的在于找出這些接口,并且逆向分析出每個接口的參數,加密方法,解密方法。最后也提醒一下,這個項目僅供學習使用,請勿從事商業行為或進行破壞版權行為~

參考資料

[1]NeteaseCloudMusicApi:?https://github.com/Binaryify/NeteaseCloudMusicApi

[2]NeteaseCloudMusicApi:?https://github.com/Binaryify/NeteaseCloudMusicApi

[3]Express:?https://www.expressjs.com.cn/

[4]Axios:?https://www.axios-http.cn/

[5]crypto.js:?https://github.com/Binaryify/NeteaseCloudMusicApi/blob/master/util/crypto.js

[6]NeteaseCloudMusicApi:?https://github.com/Binaryify/NeteaseCloudMusicApi

文章轉自微信公眾號@理想青年實驗室

上一篇:

從 DeFi 到 PayFi: 加密支付生態的崛起與未來

下一篇:

Perplexity:用答案引擎挑戰Google | 萬字長文
#你可能也喜歡這些API文章!

我們有何不同?

API服務商零注冊

多API并行試用

數據驅動選型,提升決策效率

查看全部API→
??

熱門場景實測,選對API

#AI文本生成大模型API

對比大模型API的內容創意新穎性、情感共鳴力、商業轉化潛力

25個渠道
一鍵對比試用API 限時免費

#AI深度推理大模型API

對比大模型API的邏輯推理準確性、分析深度、可視化建議合理性

10個渠道
一鍵對比試用API 限時免費