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

2017年02月16日

使用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需要进行以下修改

<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>

并在loop.hbs中加入以下css

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