koa+mongodb打造掘金关注者分析面板

前言

最近掘金更新了掘力值和等级规则,大部分用户都带上了等级徽章,并且每一个人的掘力值也都很清晰明了,我想这也是掘金激励用户输出高质量文章的一种方式,当看到本身掘力值不断增加和等级不断升高的时候,想必心里都会有种成就感。看到本身的掘力值后,发现本身还须要继续努力,继续分享更多本身的开发经验和好的想法。html

那么此次又要搞什么事情呢?卖个关子先放张效果图前端

idea

看过我文章的朋友应该感受到了,我喜欢分享本身作一个小项目或者小工具的经验,分享针对细节或者某个知识点的内容不多。我想这个和本身的爱好有很大关系,我喜欢从零完成一个项目,从本身的一个想法到原型绘制,而后到UI设计,接着使用本身熟悉的语言写先后端代码,而后到先后端联调,最后优化加部署到服务器。这一系列的过程我感受本身能学到更多的东西,也能从这一系列流程中向外扩展不少知识点和发现作项目中本身容易忽略的细节。vue

此次的掘金粉丝(其实也不能说粉丝,主要是关注者和关注了很差区分😂)分析工具也是个人一个突发奇想,由于加入掘金写的第一篇文档就是《掘金最热文章收藏评论分析》,但那个项目也只是简单的获取文章的基础数据,其实也谈不上分析,并且如今回过头看界面简直有点粗糙(感受就像回看初中杀马特般的照片同样,哈哈)。因此此次就趁着掘金把掘力值和等级上线,来一个我的数据分析,其实主要是粉丝数据分析和关注的用户分析。node

主要功能

  • 根据用户ID获取用户的粉丝或关注的用户数据
  • 分析粉丝或关注用户,发布文章、文章获赞、文章阅读数、粉丝数、掘力值TOP10
  • 分析粉丝或关注用户等级分布
  • 我的成就面板
  • 更多分析功能后续开发中...(期待你的建议)

体验与源码

体验地址juejinfan.xkboke.comgit

github:github.com/gengchen528… (若是喜欢的话,欢迎给个star)github

为了更好的方便你们体验,目前已经部署到个人服务器了,能够经过juejinfan.xkboke.com来访问,必定要是https,因为服务器带宽限制,可能刚开始加载会比较慢,请耐心等待,或者你能够直接把源码部署在本地,这样速度会快一点。若是你的粉丝比较多的话,在点击分析后也会等较长时间,不过点击后,你能够等四五分钟后再来看,数据会在爬取完成后立马加载出来的。web

注:mongodb

  • uid指的是用户id,并非用户名,能够在掘金个人主页浏览器上方地址栏看到数据库

  • token须要点击到任意用户的主页,而后打开控制台,刷新页面,能够看到get_multi_user这个请求,在请求参数一栏找到tokenexpress

uid查找

token查找

安装

前提要安装好mongodb,而且是默认端口。若是端口已更改请在/monogodb/config.js中修改端口号

git clone https://github.com/gengchen528/juejinAnalyze.git
cd juejinAnalyze
npm install 
npm run start

复制代码

若是执行npm run dev,请全局安装nodemon,若是使用pm2,请全局安装pm2

技术栈

  • koa
  • mongoose
  • superagent
  • pm2

此次分析使用了相对expresss而言比较轻量的koa,数据库就使用了mongodb,爬取数据的主要就是好搭档superagent了。

掘金接口分析

我的主页信息获取

看到登陆页面,可能你们会问既然是爬取,为何还须要token呢,这个就要来讲一下掘金渲染我的主页的方式了。通过分析后,我发如今没有登陆的状况下,掘金是采用ssr(基于vue的服务端渲染)方式渲染的,这种方式渲染出来的页面爬取起来是比较繁琐的。然而登陆后会发现页面上的数据都是从接口中获取的,这个数据看起来就很开心了,基本上全部须要的数据都有了。那么这个接口须要哪些参数呢,通过测试后发现主要有四个参数ids,token,src,cols,因此这里就明白为什么登陆页有token了吧。

  • ids: 用户id,浏览器地址栏能够找到

  • token: 打开控制台,找到get_multi_user这个请求后能够找到,这个接口必须是登录后打开别人的主页才有,在本身主页是没有这个请求的。
  • src: 来源web(能够默认为web)
  • cols: 须要获取的用户信息(默认)

ssr

未登陆状态

登陆后

所需参数

粉丝及关注的用户列表获取

粉丝列表及关注的用户列表最初的时候遇到了不少问题,由于刚开始找到接口后,发现并非简单的分页,每次只能获取20条数据。并且每次参数都不相同,第一次获取会发现基本的参数只有三个uid,currentUid,src,可是在加载下一页数据时会发现多了一个参数before,那么这个before是怎么来的呢。刚开始的时候为了找到这个规律,我请求了数十次,而且把每次的before参数写出来,最后发现居然没有一点规律,我瞬间怀疑了人生😒,难道个人想法就这么夭折了么。还好当我打开每一个数组找规律时,发现了原来before取的是上个请求中最后一个用户的关注时间,既然知道了规律就很简单了,开始卷起袖子撸代码。

第一次请求

分页请求

核心代码

目录结构

schema设计

最初的时候只设计了用户表,存的都是用户基本信息和粉丝列表follower和关注的用户列表followees,后来在完成第一个版本的开发后,发现若是已经查询过的用户再次查询后还会再次爬取和写入数据,这个就比较消耗服务器的资源了,而后就增添了一个子表searchSchema.js用来存放已查询过用户的状态

mongodb/schema.js

const mongoose = require('./config')
const Schema = mongoose.Schema

let jueJinUser = new Schema({
	uid: {type:String,unique:true,index: true,}, // 用户Id
	username: String, // 用户名
	avatarLarge: String, // 头像
	jobTitle: String, // 职位
	company: String, // 公司
	createdAt: Date, // 帐号注册时间
	
	rankIndex: Number, // 排名,级别
	juejinPower: Number, // 掘力值
	postedPostsCount: Number, // 发布文章数
	totalCollectionsCount: Number, // 得到点赞数
	totalCommentsCount: Number, // 得到评论总数
	totalViewsCount: Number, // 文章被阅读数
	
	subscribedTagsCount: Number, // 关注标签数
	collectionSetCount: Number, // 收藏集数
	
	likedPinCount: Number, // 点赞的沸点数
	collectedEntriesCount: Number, // 点赞的文章数
	pinCount: Number, // 发布沸点数
	
	postedEntriesCount: Number, // 分享文章数
	
	purchasedBookletCount: Number, // 购买小册数
	bookletCount: Number, // 撰写小册数
	
	followeesCount: Number, // 关注了多少人
	followersCount: Number, // 关注者
	
	level: Number, // 等级
	
	topicCommentCount: Number, // 话题被评论数
	viewedEntriesCount: Number, // 猜想是主页浏览数
	
	followees: {type:Array,default: []}, // 存放你关注的列表
	follower: {type:Array,default: []} // 存放粉丝列表
})

module.exports = mongoose.model('JueJinUser', jueJinUser)
复制代码

mongodb/searchSchema.js

const mongoose = require('./config')
const Schema = mongoose.Schema

// 掘金用户查询表: 记录已经查询过的用户,防止重复爬取数据,同时记录爬取状态
let JueJinSearch = new Schema({
	uid: {type:String,unique:true,index: true,}, // 用户Id
	follower: Boolean, // 是否查询过粉丝
	followees: Boolean, // 是否查询过关注用户
	followerSpider: String, // 粉丝爬取状态  success 爬取完成  loading 爬取中  none 未爬取
	followeesSpider: String // 关注用户爬取状态  success 爬取完成  loading 爬取中  none 未爬取
})

module.exports = mongoose.model('JueJinSearch', JueJinSearch)
复制代码

koa路由配置

目前提供了5个接口

  • /api/getUserFlower:爬取粉丝列表
  • /api/getUserFlowees:爬取关注的用户列表
  • /api/getSpiderStatus:获取爬取状态
  • /api/getCurrentUserInfo:获取查询用户的基本信息
  • /api/getAnalyzeData: 获取分析数据

config/koa.js

const Koa = require("koa")
const Router = require("koa-router")
const path = require('path')
const bodyParser = require('koa-bodyparser')
const koaStatic = require('koa-static')
const ctrl = require("../controller/index")
const app = new Koa()
const router = new Router()
const publicPath = '../public'
app.use(bodyParser())
app.use(koaStatic(
	path.join(__dirname, publicPath)
))
router.post('/api/getUserFlower', async(ctx, next) => { // 爬取并写入关注者信息
	let body = ctx.request.body;
	let res = await ctrl.spiderFlowerList(body);
	ctx.response.status = 200;
	ctx.body = { code: 200, msg: "ok", data: res.data }
	next()
})

router.post('/api/getUserFlowees', async(ctx, next) => { // 爬取并写入关注信息
	let body = ctx.request.body;
	let res = await ctrl.spiderFloweesList(body);
	ctx.response.status = 200;
	ctx.body = { code: 200, msg: "ok", data: res.data }
	next()
})

router.post('/api/getSpiderStatus', async(ctx, next) => { // 获取爬取状态
	let body = ctx.request.body;
	let res = await ctrl.spiderStatus(body);
	ctx.response.status = 200;
	ctx.body = { code: 200, msg: "ok", data: res.data }
	next()
})

router.post('/api/getCurrentUserInfo', async(ctx, next) => { // 获取当前用的基本信息
	let body = ctx.request.body;
	let res = await ctrl.getUserInfo(body)
	ctx.response.status = 200;
	ctx.body = { code: 200, msg: "ok", data: res }
	next()
})
router.post('/api/getAnalyzeData', async(ctx, next) => { // 获取你的关注者分析数据
	let body = ctx.request.body;
	let res = await ctrl.getAnalyze(body)
	ctx.response.status = 200;
	ctx.body = { code: 200, msg: "ok", data: res }
	next()
})

const handler = async(ctx, next) => {
	try {
		await next();
	} catch (err) {
		console.log('服务器错误',err)
		ctx.respose.status = 500;
		ctx.response.type = 'html';
		ctx.response.body = '<p>出错啦</p>';
		ctx.app.emit('error', err, ctx);
	}
}

app.use(handler)
app.on('error', (err) => {
	console.error('server error:', err)
})

app.use(router.routes())
app.use(router.allowedMethods())
app.listen(9080, () => {
	console.log('juejinAnalyze is starting at port 9080')
	console.log('please  Preview at  http://localhost:9080')
})
复制代码

controller

controller里是最主要的爬取和插入数据的逻辑,标准的后端项目应该再拆分一层服务用来给controller调用,可是因为项目比较小,这里就没有作拆分。重要的逻辑部分在代码中都有注释。因为趁着周末两天作的项目,因此这里的逻辑有些比较臃肿,后期会慢慢优化一下,有兴趣的也能够fork下来后自行修改为本身想要的效果。

const {request} = require("../config/superagent")
const constant = require("../untils/constant")
const model = require("../mongodb/model")

function getLastTime(arr) {
	let obj = arr.pop()
	return obj.createdAtString
}

// 爬取用户信息并插入到mongodb
// @ids 用户id  @token token @tid 关注者用户id
async function spiderUserInfoAndInsert(ids, token, tid, type) {
	let url = constant.get_user_info
	let param = {
		token: token,
		src: constant.src,
		ids: ids,
		cols: constant.cols
	}
	try {
		let data = await request(url, 'GET', param)
		let json = JSON.parse(data.text)
		let userInfo = json.d[ids]
		let insertData = {
			uid: userInfo.uid,
			username: userInfo.username,
			avatarLarge: userInfo.avatarLarge,
			jobTitle: userInfo.jobTitle,
			company: userInfo.company,
			createdAt: userInfo.createdAt,
			rankIndex: userInfo.rankIndex, // 排名,级别
			juejinPower: userInfo.juejinPower, // 掘力值
			postedPostsCount: userInfo.postedPostsCount, // 发布文章数
			totalCollectionsCount: userInfo.totalCollectionsCount, // 得到点赞数
			totalCommentsCount: userInfo.totalCommentsCount, // 得到评论总数
			totalViewsCount: userInfo.totalViewsCount, // 文章被阅读数
			subscribedTagsCount: userInfo.subscribedTagsCount, // 关注标签数
			collectionSetCount: userInfo.collectionSetCount, // 收藏集数
			likedPinCount: userInfo.likedPinCount, // 点赞的沸点数
			collectedEntriesCount: userInfo.collectedEntriesCount, // 点赞的文章数
			pinCount: userInfo.pinCount, // 发布沸点数
			postedEntriesCount: userInfo.postedEntriesCount, // 分享文章数
			purchasedBookletCount: userInfo.purchasedBookletCount, // 购买小册数
			bookletCount: userInfo.bookletCount, // 撰写小册数
			followeesCount: userInfo.followeesCount, // 关注了多少人
			followersCount: userInfo.followersCount, // 关注者
			level: userInfo.level, // 等级
			topicCommentCount: userInfo.topicCommentCount, // 话题被评论数
			viewedEntriesCount: userInfo.viewedEntriesCount, // 猜想是主页浏览数
		}
		await model.user.insert(insertData)
		if (ids !== tid) {
			if (type === 'followees') {
				updatefollower(ids, tid) // 更新关注你的用户列表
				updatefollowees(tid, ids) // 更新你关注用户的列表
			} else {
				updatefollower(tid, ids) // 更新关注你的用户列表
				updatefollowees(ids, tid) // 更新你关注用户的列表
			}
		}
		return 'ok'
	} catch (e) {
		console.log('用户信息获取失败',ids, e,)
	}
}

// 更新用户的关注列表
// @uId 用户id @tId 关注的用户Id
async function updatefollowees(uId, tId) {
	let data = {
		uid: uId,
		followUid: tId
	}
	model.followees.updatefollowees(data)
}

// 更新用户的被关注列表
// @uId 关注的用户id @tId 被关注的用户Id
async function updatefollower(uId, tId) {
	let data = {
		uid: uId,
		followUid: tId
	}
	model.follower.updatefollower(data)
}

// 爬取用户的关注者列表
// @uid 用户的id @token token @before 循环获取关注列表的必须参数,取上一组数据中最后一个数据的关注时间
async function getFollower(uid, token, before) {
	let param = {
		uid: uid,
		src: constant.src
	}
	if (before) {
		param.before = before
	}
	try {
		let url = constant.get_follow_list
		let list = await request(url, 'GET', param)
		let followList = list.body.d
		followList.forEach(async function (item) { // 循环获取关注者的信息
			await spiderUserInfoAndInsert(item.follower.objectId, token, uid, 'follower')
		})
		if (followList&&followList.length === 20) {  // 获取的数据长度为20继续爬取
			let lastTime = getLastTime(followList)
			await updateSpider(uid, 'followerSpider', 'loading') // 更新爬取状态为loading
			await getFollower(uid, token, lastTime)
		} else {
			await updateSpider(uid, 'follower', true) // 设置已经爬取标志
			await updateSpider(uid, 'followerSpider', 'success') // 更新爬取状态为success
		}
	} catch (err) {
		console.log('获取粉丝列表失败',err)
		return {data: err}
	}
}

// 更新爬取状态与结果
// @uid 用户id @key 更新的字段 @value 更新的值
async function updateSpider(uid, key, value) {
	let condition = {
		uid: uid,
		key: key,
		value: value
	}
	model.search.update(condition)
}

// 爬取你关注的列表
// @uid 用户的id @token token @before 循环获取关注列表的必须参数,取上一组数据中最后一个数据的关注时间
async function getFollowee(uid, token, before) {
	let param = {
		uid: uid,
		src: constant.src
	}
	if (before) {
		param.before = before
	}
	try {
		let url = constant.get_followee_list
		let list = await request(url, 'GET', param)
		let followList = list.body.d
		followList.forEach(async function (item) { // 循环获取关注者的信息
			await spiderUserInfoAndInsert(item.followee.objectId, token, uid, 'followees')
		})
		if (followList.length === 20) {
			let lastTime = getLastTime(followList)
			await updateSpider(uid, 'followeesSpider', 'loading') // 更新爬取状态为loading
			await getFollowee(uid, token, lastTime)
		} else {
			await updateSpider(uid, 'followees', true) // 设置已经爬取标志
			await updateSpider(uid, 'followeesSpider', 'success') // 更新爬取状态为loading
		}
	} catch (err) {
		console.log('获取关注者列表失败',err)
		return {data: err}
	}
}

// 用户数据分析
// @uid 用户id  @top 可配置选取前多少名  @type 获取数据类型:粉丝 follower 关注的人 followees
async function getTopData(uid, top, type) {
	let data = {
		uid: uid,
		top: parseInt(top),
		type: type
	}
	try {
		let article = model.analyze.getTopUser(data, 'postedPostsCount')
		let juejinPower = model.analyze.getTopUser(data, 'juejinPower')
		let liked = model.analyze.getTopUser(data, 'totalCollectionsCount')
		let views = model.analyze.getTopUser(data, 'totalViewsCount')
		let follower = model.analyze.getTopUser(data, 'followersCount')
		let level = model.analyze.getLevelDistribution(data)
		let obj = {
			postedPostsCount: await article,
			juejinPower: await juejinPower,
			totalCollectionsCount: await liked,
			totalViewsCount: await views,
			followersCount: await follower,
			level: await level
		}
		return obj
	} catch (err) {
		console.log('err', err)
		return err
	}
}

module.exports = {
	spiderFlowerList: async (body) => {  // 获取用户的关注者列表
		let uid = body.uid
		let token = body.token
		let searchStatus = await model.search.findOrInsert({uid: uid})
		if (searchStatus.followerSpider == 'success') {
			return {data: 'success'}
		} else if (searchStatus.followerSpider == 'loading') {
			return {data: 'loading'}
		} else if (searchStatus.followerSpider == 'none') {
			spiderUserInfoAndInsert(uid, token, uid) // 把本身的信息也插入mongodb
			getFollower(uid, token)
			return {data: 'none'}
		}
	},
	spiderFloweesList: async (body) => { // 获取用户的关注列表
		let uid = body.uid
		let token = body.token
		let searchStatus = await model.search.findOrInsert({uid: uid})
		if (searchStatus.followeesSpider == 'success') {
			return {data: 'success'}
		} else if (searchStatus.followeesSpider == 'loading') {
			return {data: 'loading'}
		} else if (searchStatus.followeesSpider == 'none') {
			spiderUserInfoAndInsert(uid, token, uid) // 把本身的信息也插入mongodb
			getFollowee(uid, token)
			return {data: 'none'}
		}
	},
	spiderStatus: async (body) => {
		let uid = body.uid
		let type = body.type + 'Spider'
		let spiderStatus = await model.search.getSpiderStatus({uid: uid, type: type})
		if (spiderStatus[type] === 'loading' || spiderStatus[type] === 'none') {
			return {data: false}
		} else if (spiderStatus[type] === 'success') {
			return {data: true}
		}
	},
	getUserInfo: async (body) => { // 获取当前用户基本信息
		let uid = body.uid
		let data = {
			uid: uid
		}
		let result = await model.user.getUserInfo(data)
		return result
	},
	getAnalyze: async (body) => { // 获取关注者数据分析
		let uid = body.uid
		let top = body.top
		let type = body.type
		let res = await getTopData(uid, top, type)
		return res
	}
}

复制代码

页面设计

为了摆脱最初时候的杀马特形象,此次采用了比较流行的大数据面板展现。不过整个页面的设计主要归功于拥有一个作设计的女盆友(没有任何撒粮的行为😆,主要是为了感谢),在这里感谢一下提供帮助的女盆友😂,感谢牺牲周末时间陪我改设计图。 另外因为考虑到不一样屏幕适配问题,在前端代码上只采用了等比缩小放大效果,因此有的屏幕下显示会有点变形,这属于正常状况。

分析截图

看了本身的概况后发现已经加入掘金851天了,发布文章12篇,发布沸点11条,得到关注者211位,感谢各位关注个人用户。

最后

作完整个小项目后,最大的想法就是把整个过程写下来,不只是分享给你们,更是从新回顾整个项目过程当中遇到的问题和当时解决问题的方法有何改进之处。但愿你们可以喜欢这个项目,同时也提醒一下你们不要拿着个项目作坏事啊,这个项目主要是用来技术交流和帮助你们查看一下粉丝和关注的人的数据分析。若是在使用过程当中遇到任何问题均可以在下方留言,或者直接加微信联系我,若是看到了我会及时回复。

项目地址:

github:github.com/gengchen528… (若是喜欢的话,欢迎给个star)