上一篇文章这个高颜值的开源第三方网易云音乐播放器你值得拥有介绍了一个开源的第三方网易云音乐播放器,这篇文章我们来详细了解一下其中使用到的网易云音乐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 = appappExt.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) => {// 参数注入客户端IPconst obj = [...params]// 处理ip,为了实现IPv4-IPv6互通,IPv4地址前会增加::ffff:let ip = req.ipif (ip.substr(0, 7) == '::ffff:') {ip = ip.substr(7)}obj[3] = {...obj[3],ip,}return request(...obj)})// 请求成功后,获取响应中的cookie,并且通过Set-Cookie响应头来将这个cookie设置到前端浏览器上const cookies = moduleResponse.cookieif (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) {// 请求失败处理// 没有响应体,返回404if (!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'] = ipheaders['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) => {// ...// 设置cookieif (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, //设备modelbuildver: 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_Uif (cookie.MUSIC_A) header['MUSIC_A'] = cookie.MUSIC_Aheaders['Cookie'] = Object.keys(header).map((key) =>encodeURIComponent(key) + '=' + encodeURIComponent(header[key]),).join('; ')data.header = headerdata = encrypt.eapi(options.url, data)url = url.replace(/\w*api/, 'eapi')}// ...})
}

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

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。

至于这些是怎么知道的呢,要么就是网易云音乐内部人士(基本不可能),要么就是进行逆向了,比如网页版的接口,打开控制台,发送请求,找到在源码中的位置, 打断点,查看请求数据结构,阅读压缩或混淆后的源码慢慢进行尝试,总之,向这些大佬致敬。

继续:

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 = agentsettings.httpAgent = agentsettings.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 = 502answer.body = { code: 502, msg: err }reject(answer)})})
}

最后一步就是使用Axios发送请求了,处理了一下响应的cookie,保存到响应对象上,方便后续使用,另外处理了一些状态码,可以看到try-catch的使用比较多,至于为什么呢,估计要多尝试来能知道到底哪里会出错了,有兴趣的可以自行尝试。

总结

本文通过源码角度了解了一下NeteaseCloudMusicApi项目的实现原理,可以看到整个流程是比较简单的。无非就是一个请求代理,难的在于找出这些接口,并且逆向分析出每个接口的参数,加密方法,解密方法。最后也提醒一下,这个项目仅供学习使用,请勿从事商业行为或进行破坏版权行为~

开源的网易云音乐API项目都是怎么实现的?相关推荐

  1. 一个开源的网易云音乐api项目

    昨天在Github上发现了一个开源的音乐api项目,restful风格,Json格式,提供的功能真的是史上最全,足够你开发一款属于自己的客户端了.而且作者非常贴心,除了开源了这个项目外还提供了一份详细 ...

  2. 网易云音乐API,的调用方法 ,vue项目中(在本地使用)

    1. 在cmd 命令行下:安装并启动: git clone https://github.com/Binaryify/NeteaseCloudMusicApi.git /* 下载 */cd Netea ...

  3. Github项目推荐:网易云音乐 API

    网易云音乐 API Github项目地址:https://github.com/Binaryify/NeteaseCloudMusicApi Github文档地址:https://binaryify. ...

  4. Github每日Rank推荐:网易云音乐API

    今天要推荐的github开源项目是NeteaseCloudMusicApi,即网易云音乐API,安装所需环境即可实现真实调用网易云音乐 API,包括注册.登录.搜索歌单歌词.歌曲点评等功能,请看客老爷 ...

  5. Vue3+node.js网易云音乐实战项目(三)

    页面 一.头部导航栏布局 二.轮播图的实现 三.请求网易的banner图 四 链接 一.头部导航栏布局 首先我们看最上面这里的布局,大致可分为三个模块,顶部左边,顶部中间,顶部右边 那么我们在comp ...

  6. Docker 使用网易云音乐 API

    背景 最近想写一个一小程序,需要搜索音乐歌曲相关信息.找到了一个叫 NeteaseCloudMusicApi 网易云音乐 API 的GitHub 开源项目. 它的 GitHub 地址: Binaryi ...

  7. Vue3+node.js网易云音乐实战项目(五)

    推荐歌单详细页面顶部 1.推荐歌单详细页面 1.1.导航条和背景 1.2.头像和简介 1.3.头部完整代码 1.4.链接 实现效果 1.推荐歌单详细页面 1.1.导航条和背景 推荐歌单页面做好后,我们 ...

  8. 网易云音乐API使用

    网易云音乐API使用 网易云音乐API 1.安装 $ git clone git@github.com:Binaryify/NeteaseCloudMusicApi.git 或者 https://gi ...

  9. Vue3+node.js网易云音乐实战项目(八)

    播放界面实现 1.准备工作 2.顶部布局 3.中部唱片部分布局 4.底部部分布局 最后一个页面还没写完,由于我要去比赛,所以暂时先写到这,等放假了再写 其他页面可以看我页面专栏 Vue3实战项目-网易 ...

最新文章

  1. 计算机网络第七版(谢希仁著)课后习题答案
  2. Flutter 完美的验证码输入框(2 种方法)
  3. java创建 xml_java创建和读取xml
  4. Linux升级ssh、ssl
  5. (原创)C++ IOC框架
  6. 【完全开源】百度地图Web service API C#.NET版,带地图显示控件、导航控件、POI查找控件...
  7. 最小生成树的普里姆算法c实现
  8. Maven打包时报Failed to execute goal org.apache.maven.plugins:maven-war-plugin:2.2:war解决方案
  9. Java KeyTool 的使用
  10. 推荐一个 Linux 刻盘工具 gcdw(转)
  11. Android TextView 上下滑动 左右滑动设置
  12. 基于cosmol软件的光纤热力学分析
  13. 2023-03-27-安装office365显示Microsoft Office 专业增强版 2016
  14. 思想,坚持,信仰,一切
  15. 信息系统项目管理师 - 项目立项管理
  16. Ubuntu 16.10 禁用 Guest 访客模式
  17. python cv2读取图片后进行通道变换以及PIL阅读图像的通道转换
  18. 《CalliGAN: Style and Structure-aware Chinese Calligraphy Character Generator》论文笔记
  19. Android程序员必备的六大顶级开发工具,快加入你的清单!
  20. UltraEdit编辑器的宏使用

热门文章

  1. 购买啊里空间,操作ftp上传网站,购买域名
  2. DataBase_事务的ACID属性
  3. obs点歌插件 html效果,OBS 歌曲显示插件使用图文教程
  4. 在c语言中用冒泡法比较大小,c语言大小排序,用冒泡法和选择排序法
  5. 楚留香服务器维护3月8,楚留香3月8日更新了什么?楚留香2019年3月8日更新内容一览...
  6. 缠中说禅 论语 详解 全集列表
  7. Matlab中的“prod”函数
  8. 准备自己搭建一套智能家居系统
  9. mysql安装 1067_Mysql安装出现1067错误解决方案
  10. ASUS华硕天选Air笔记本FX517ZC原装出厂Win11系统