为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>
<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;
}