阅读 3678

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

前言

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

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

idea

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

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

主要功能

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

体验与源码

体验地址juejinfan.xkboke.com

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

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

注:

  • uid指的是用户id,并不是用户名,可以在掘金我的主页浏览器上方地址栏看到

  • token需要点击到任意用户的主页,然后打开控制台,刷新页面,可以看到get_multi_user这个请求,在请求参数一栏找到token

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)

同时也欢迎关注我的公众号,轻发语音会有惊喜。不定期分享文章~~

个人博客:www.xkboke.com

个人微信:

关注下面的标签,发现更多相似文章
评论