为Ghost Blog添加访问/阅读计数

使用Ghost Blog已经有半年了,当初借帮朋友搭建博客的契机自己也弄了一个,现在看来应该还会继续更新下去。

Ghost本身不具备访问计数,其实可以用Google Analytics来统计访问量,只要在管理界面的Code Injection中加入相应的代码即可,在Google Analytics控制台中就可以看到。但如果涉及到某篇文章的阅读计数,似乎并没有现成可用的东西。

在Google上搜索了一下,关于添加阅读计数器的文章并不多,其中Blessing
Studio中的《为 Ghost 博客添加页面访问计数器》
已经用PHP实现了一个比较完备的计数器(这个博客本身的完成度很高,内容也赞)。但由于我的VPS配置过低,内存已经不够用了,再跑一个PHP环境不太适合,因此打算在Ghost基础上用实现这个功能,顺便学习一下现在大红大紫的Node.js。

思路

实现思路其实都大同小异。在服务端实现计数逻辑,并将数值进行持久化;在浏览器端网页中加入JS与服务端交互,调用计数器并在必要的时候将结果取回。

翻阅了一下Ghost的文档,官方似乎并没有提供自定义模块或者插件的方式,因此服务器对外暴露的API无法扩充,要实现额外功能就不可避免的需要调整Ghost本身的代码。这一点还是Blessing Studio那种方式比较好,完全独立,Ghost本身的版本更新并不会影响到计数器的逻辑。

不过好在Node.js的模块化设计和Ghost源代码本身具有良好的结构,因此可以尽量浅层、独立地修改服务端代码。至于网页就无需考虑了,Ghost对静态网页的渲染本身就是基于主题中的hbs模版,自定义主题的同时已经改过了,无需纠结。

下面是简单实现,自己的Node.js也是刚入门,可能会有Bug

服务器端

将计数器的实现全部放在一个js文件中,形成一个模块,姑且就叫viewer-counter.js好了,将这个文件放在Ghost的根目录下。

viewer-counter.js的作用是在Ghost的Express上加入了一个middleware来拦截发送到/management/count的请求。拦截到请求之后,根据请求中的slug参数来确定读者访问的是哪个网页,并对该网页对应的计数进行累加。由于Node.js的回调是在主进程中进行,因此也无需考虑并发之类的情况,这一点很惬意😎。然后,再加入middleware来拦截/management/counter,这个middleware的主要作用是给浏览器端提供访问量数据。另外,还需要有一个定时器把内存中的数据定期入库,这样做的好处是可以减少IO,但是假如在定时器触发前服务器挂掉,那这段时间内的阅读计数就没了。

持久化存储采用了sqlite3,无需额外安装数据库...其实存文件也可以,但是将来如果需要查询的话会有点麻烦。

var Promise = require('bluebird');
var self = this;
//计数器
var urlCounter;
//数据库配置
var knex = require('knex')({
  client: 'sqlite3',
  connection: {
    filename: "./view_counter.db"
  }
});
//初始化操作,读取关机之前的数据.
initDB();


//计数器加一
function countViewer(appServer){
	appServer.use('/management/count',function(req,res,next){
		var visiturl = req.query.slug;
		if(visiturl!=undefined && visiturl!=null && visiturl!=''){
			if(urlCounter[visiturl] == undefined){
				urlCounter[visiturl] =1;
			}else{
				var count = urlCounter[visiturl];
				urlCounter[visiturl] = ++count;
				//console.log(visiturl + ':' + count);
			}
		}
		res.status(200).end();
	});
	
	appServer.use('/management/counter',function(req,res,next){
		//var reqBody = req.body;
		//console.log(reqBody);
		var visitUrlStr = req.query.slug;
		if(visitUrlStr== null || visitUrlStr==''){
			res.status(200).end();
		}else{
			var result = [];
			var visitUrls = visitUrlStr.split(',');
			Promise.map(visitUrls,function(visitUrl){
				var vCount = urlCounter[visitUrl];
				if(vCount != undefined){
					var row = {};
					row.id = visitUrl;
					row.value = vCount.toString();
					result.push(row);
				}
			}).then(function(){
				res.json(result);
			});
		}
	});
	
	appServer.use('/management/counter/all',function(req,res,next){
		res.send(urlCounter);
	});
}

//根据url获取页面访问次数
function getViewerCount(slug){
	if(urlCounter[visiturl]==undefined){
		return 0;
	}else{
		return urlCounter[visiturl];
	}
}

//初始化db
function initDB(){
	knex.schema.createTableIfNotExists('viewer_counter',function(table){
		table.string('url');
		table.integer('visit_count');
	}).then(function(result){
		//初始化数据
		loadLastData();
		//计时器,负责入库
		setTimeout(function(){
			saveViewerCount();
		},60000);
	});
}

//初始化计数器
function loadLastData(){
	//获取上一次的数据
	urlCounter = {};
	knex.select('url','visit_count').table('viewer_counter').then(function(rows) {
		rows.forEach(function(row){
			debugger;
			//console.log(row.url + ':' + row.visit_count);
			urlCounter[row.url] = row.visit_count;
		});
	});
}

//保存入库
function saveViewerCount(){
	//console.log('saving....');
	var keys = Object.keys(urlCounter);
	knex.transaction(function(trx){
		debugger;
		knex('viewer_counter').transacting(trx).del()
		.then(function(){
			return Promise.map(keys,function(rowKey){
				//console.log(rowKey + ':'+ urlCounter[rowKey]);
				var info ={'url':rowKey,'visit_count':urlCounter[rowKey]};
				return knex.insert(info).into('viewer_counter').transacting(trx);
			});
		}).then(trx.commit).catch(trx.rollback);
	}).then(function(insert){
		//console.log(insert.length + ' url saved.');
		//等insert完成之后再开始下一次定时器
		setTimeout(function(){
			saveViewerCount();
		},60000);
	}).catch(function(error) {
		console.error(error);
				//等insert完成之后再开始下一次定时器
		setTimeout(function(){
			saveViewerCount();
		},60000);
	});
}

module.exports.countViewer=countViewer;
module.exports.getViewerCount=getViewerCount;

然后在Ghost主模块的index.js中引入该模块,并进行初始化。注意初始化必须在Ghost的express对象建立之后。

var viewerCounter = require('./viewer-counter');
// Create our parent express app instance.
parentApp = express();//这句话其实是Ghost原有的代码
viewerCounter.countViewer(parentApp);

服务端基本上就搞定了。其实还差了安全策略,这个以后再说吧...我这种乡下小行星不会有人在上面挖洞吧😏...

浏览器端

浏览器端的改动主要体现在主题的定制上,在主题模版中加入相关代码即可。

首先是post.hbs,从命名上可以看出,这个模版被用来渲染博主发布的文章。只要在其中加入js代码,有人打开该页面时将当前的url发送到/management/count中,就可以完成一次累加计数。其中包含在双大括号中的slug是一个表达式,界面渲染时,该字段会被替换成当前界面的url。

  <script type='text/javascript'>
	function addVisitCount(){
		$.ajax({
			url:'/management/count?slug={{slug}}',
			type:'get',
			success:function(data){
				console.log('success');
			}
		});
	}
	//停留3秒
	setTimeout(function(){
		addVisitCount();
	},3000);
</script>

最后,显示阅读计数就是简单地通过ajax请求,将页面的slug(slug我也不太清楚和URL的区别是什么,有可能会有坑,以后再填吧),下面是在首页列表中显示阅读计数,在具体文章中显示同理。具体需要在loop.hbs中修改。我这里是把文章列表中的slug全部拿到,一次ajax请求获取了所有文章的阅读计数,然后赋值给界面上的span。

<script type='text/javascript'>
    //获取文章的url计数
var articleSlugs = [];
var articleLinks = $('article .post-view-count');
for(var i=0;i<articleLinks.length;i++){
	articleSlugs.push($(articleLinks[i]).attr('id'));
}
var slug = encodeURI(articleSlugs.toString());
$.ajax({
	url:'/management/counter?slug='+slug,
	type:'GET',
	success:function(data){
		if(data!='' && data.length >0){
			$(data).each(function(idx,ele){
				$('#' + ele.id).text(ele.value);
			});
		}
	}
});
</script>

loop.hbs中的HTML需要进行以下修改

<script type='text/javascript'>
<footer class="post-meta">
    {{#if author.image}}<img class="author-thumb" src="{{author.image}}" alt="{{author.name}}" nopin="nopin" />{{/if}}
    {{author}}
    {{tags prefix=" on "}}
    <time class="glyphicon glyphicon-calendar post-date" datetime="{{date format="YYYY-MM-DD"}}">{{date format="YYYY-MM-DD"}}</time>
    <span id='{{slug}}' class="glyphicon glyphicon-eye-open post-view-count">0</span>
</footer>
 </script>

并在loop.hbs中加入以下css

.post-view-count{
	font-size:1.3rem;
	padding-left:12px;
	margin-left:8px;
	display:inline-block;
	white-space:nowrap;
}