Categories

Growl for Windows meets GreaseMonkey

最近学校的 CC98 论坛简直慢得令人发指,总是让我感觉又回到了蓝田时代——洗完脸刷完牙回来了一个页面还没有打开。加上考试周到了,又特别喜欢灌水,所以老是想去刷新页面看有没有新帖子,其实主要也是看的俱乐部水楼、晚安楼之类的地方,不过每次等半天页面终于出来了,发现没有新帖也觉得相当浪费时间和感情。想起 quark 之前有做过一个监视 98 的新帖子,然后用 notification-daemon 给出提示的东西,于是我想也做一个类似的东西好了,让它自己在后台刷新,网络慢点也没有关系,至少我不用一直等在那里了,有新帖的时候再关注一下好了。

不过在 Windows 下没有 notification-daemon 可以用,而且诸如 Python、Ruby 之类的脚本在 Windows 下跑起来都觉得相当别扭。记得之前看过一个叫做 Growl for Windows 的东西(这家伙的缩写真有点那个……),好像就是 Mac 下的那个 Growl 的 clone 。于是去仔细看了一下,似乎支持一套标准的协议,而且各个语言的 binding 都很全。

抓取和解析 98 帖子的 Python 库我是有现成的,不过确实还真不想在 Windows 下开一个黑框框来跑个 Python 程序,怎么看怎么别扭。然后又发现 Growl for Windows 支持订阅网络上的 notification ,这样一来就可以在实验室的机器上跑 notification server 了,这个是比较赞的,只是这样一来估计回调之类的就不好实现了——比如点一下 notification popup 能弹出一个框来输入回帖或者是在浏览器里打开该帖子的页面之类的,不过这个需求倒不是特别大。

可是当我真正开始尝试做的时候,才发现虽然 Growl for Windows 的主页上列出了各种语言的 binding ,但是其实各个 binding 的完成程度和质量真实良莠不齐,似乎要弄一个 server 也比较麻烦,稍微尝试了一下,出了各种错误,最后直接放弃了——因为发现了一个看起来似乎更好的选择:GreaseMonkey。

只要在 Firefox 里装上 Growl/GNTP 扩展之后,就可以在 GM 里脚本里来发送 Growl Notification 了,不过,每个脚本里还需要粘贴这样一段脚本:

// -- GrowlMonkey stuff below here - do not edit
GrowlMonkey = function(){
    function fireGrowlEvent(type, data){
        var element = document.createElement("GrowlEventElement");
        element.setAttribute("data", JSON.stringify(data));
        document.documentElement.appendChild(element);
 
        var evt = document.createEvent("Events");
        evt.initEvent(type, true, false);
        element.dispatchEvent(evt);
    }
 
    return {
        register : function(appName, icon, notificationTypes){
            var r = {};
            r.appName = appName;
            r.icon = icon;
            r.notificationTypes = notificationTypes;
            fireGrowlEvent("GrowlRegister", r);
        },
 
        notify : function(appName, notificationType, title, text, icon){
            var n = {};
            n.appName = appName;
            n.type = notificationType;
            n.title = title;
            n.text = text;
            n.icon = icon;
            fireGrowlEvent("GrowlNotify", n);
        }
    }
}();
 
/* json2.js 
 * 2008-01-17
 * Public Domain
 * No warranty expressed or implied. Use at your own risk.
 * See http://www.JSON.org/js.html
*/
if(!this.JSON){JSON=function(){function f(n){return n<10?'0'+n:n;}
Date.prototype.toJSON=function(){return this.getUTCFullYear()+'-'+
f(this.getUTCMonth()+1)+'-'+
f(this.getUTCDate())+'T'+
f(this.getUTCHours())+':'+
f(this.getUTCMinutes())+':'+
f(this.getUTCSeconds())+'Z';};var m={'\b':'\\b','\t':'\\t','\n':'\\n','\f':'\\f','\r':'\\r','"':'\\"','\\':'\\\\'};function stringify(value,whitelist){var a,i,k,l,r=/["\\\x00-\x1f\x7f-\x9f]/g,v;switch(typeof value){case'string':return r.test(value)?'"'+value.replace(r,function(a){var c=m[a];if(c){return c;}
c=a.charCodeAt();return'\\u00'+Math.floor(c/16).toString(16)+
(c%16).toString(16);})+'"':'"'+value+'"';case'number':return isFinite(value)?String(value):'null';case'boolean':case'null':return String(value);case'object':if(!value){return'null';}
if(typeof value.toJSON==='function'){return stringify(value.toJSON());}
a=[];if(typeof value.length==='number'&&!(value.propertyIsEnumerable('length'))){l=value.length;for(i=0;i<l;i+=1){a.push(stringify(value[i],whitelist)||'null');}
return'['+a.join(',')+']';}
if(whitelist){l=whitelist.length;for(i=0;i<l;i+=1){k=whitelist[i];if(typeof k==='string'){v=stringify(value[k],whitelist);if(v){a.push(stringify(k)+':'+v);}}}}else{for(k in value){if(typeof k==='string'){v=stringify(value[k],whitelist);if(v){a.push(stringify(k)+':'+v);}}}}
return'{'+a.join(',')+'}';}}
return{stringify:stringify,parse:function(text,filter){var j;function walk(k,v){var i,n;if(v&&typeof v==='object'){for(i in v){if(Object.prototype.hasOwnProperty.apply(v,[i])){n=walk(i,v[i]);if(n!==undefined){v[i]=n;}}}}
return filter(k,v);}
if(/^[\],:{}\s]*$/.test(text.replace(/\\./g,'@').replace(/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g,']').replace(/(?:^|:|,)(?:\s*\[)+/g,''))){j=eval('('+text+')');return typeof filter==='function'?walk('',j):j;}
throw new SyntaxError('parseJSON');}};}();}

其实就是一个简单的 API 包装(还附带了一个简易的 json encoder),有了这个之后,剩下的就是要在 GM 脚本里注册 Notification 类别以及发送 Notification 了。注册的代码像这个样子:

var ntNewPost = {};
ntNewPost.name = "newpost";
ntNewPost.displayName = "New Post";
ntNewPost.enabled = true;
 
var types = [ntNewPost];
GrowlMonkey.register(AppName, '', types);

可以一次注册多种消息类型,发送 notification 的时候需要给定 AppName 和类别名字。notification 发送的代码像这个样子(我就直接把整个函数给出来了,这里用了 jQuery):

function notifyPosts(posts, thread) {
    $(posts).each(function (index, p) {
        GrowlMonkey.notify(AppName, "newpost", p.author + "@" + thread.name, 
   filterUBB(p.content),
   "http://www.cc98.org/face/face" + p.face + ".gif");
    });
}

前面两个参数分别是 AppName 和 notification 类别的名字,然后是标题、内容和图标,似乎这个 API 里没有提供诸如 callbackurl 之类的支持,要不然也许可以点击 notification 的时候打开指定的帖子或者回复的页面。我把那个 Firefox 扩展 xpi 文件解压开来看了看里面的内容,似乎代码比较少,竟然没有看明白到底是在哪里处理了它这里声明的 GrowlNotify 事件,不过暂时这样的需求也不大,所以先不管了。

有了 notify 的工具,剩下的就是监视帖子内容了,其实说是监视,无非也就是定时刷新一下,看有没有更新。一开始我想用 GM 提供的 persistence storage ,可是后来想想没有必要,如果我三天不开浏览器(虽然有点不太可能),难不成下次开的时候还把之前的帖子一股脑全部弹出来吗?一般旧的帖子会专门去考古看一下,要监视的也就是打开浏览器之后新出现的帖子,所以这个信息就保存在内存中就可以了。这样一来,事情就好办很多了,因为是在 GM 里,什么 cookie 呀之类的都不用管,在加上 jQuery ,写起来也相当方便。首先定义要监视的帖子的列表:

var WatchThreads = [
    {board:60, thread:541996, name:"水库"},
    {board:60, thread:3017942, name:"征人打乒乓"},
    {board:60, thread:2482989, name:"吃饭楼"},
    {board:60, thread:1999585, name:"晚安楼"}
];

然后,在文档加载完成之后进行 Growl 注册,同时在 Growl 注册完成之后对监视的帖子进行初始化,也就是获取目前的帖子数目:

$(document).ready(registerGrowl);
 
function registerGrowl() {
    /* ...register Growl... */
 
    initWatchThreads();
}
 
function initWatchThreads() {
    $(WatchThreads).each(function (idx, w) {
        w.lastFloor = -1;
        w.newLastFloor = -1;
        loopWatchThread(w, MaxPage);
    });
}

这里我直接把 lastFloor 设置为 -1 去调用监视函数,让它自己去设置这个值,这样就不用再专门再写一个初始化函数了。这里的 newLastFloor 可以看作一个监视中间过程中的临时变量,由于 js 的执行特性是异步 + 回调串联起来的,和传统的直线顺序执行模型还有一些不一样,所以有些地方看起来有点奇怪。这里的 loopWatchThread 里的 Thread 并不是指“线程”,而是指一个讨论“贴”,虽然这里看起来有点像为每个要跟踪的帖子启动了一个线程(而且效果也差不多),但是实际上是通过 setTimeout 串联起来的异步调用而已。loopWatchThread 的定义如下:

function loopWatchThread(t, page) {
    $.get('/dispbbs.asp', {boardID:t.board, ID:t.thread, star:page},
          function(data) {
              feedPage(data, t);
          });
}
 
function feedPage(page, thread) {
    var parts = page.split(RePostSep);
    var index = parts.length-1;
 
    var lastPost = parsePost(parts[index--]);
    if (DebugEnable) {
        notifyPosts([lastPost], thread);
    }
 
    var posts = [];
    if (thread.newLastFloor == -1) {
        thread.newLastFloor = lastPost.floor;
    }
 
    if (thread.lastFloor == -1) {
        thread.lastFloor = thread.newLastFloor;
        thread.newLastFloor = -1;
        setTimeout(function (){loopWatchThread(thread, MaxPage)}, WatchInterval);
    } else {
        if (lastPost.floor > thread.lastFloor) {
            posts.push(lastPost);
 
            while (index > 0) {
                post = parsePost(parts[index]);
                if (post.floor > thread.lastFloor) {
                    posts.push(post);
                } else {
                    break;
                }
 
                index -= 1;
            }
            notifyPosts(posts, thread);
 
            if (index < 1) {
                // need check more pages
                var prevPage = Math.floor((posts[posts.length-1].floor-1 + 9)/10);
                loopWatchThread(thread, prevPage);
            } else {
                thread.lastFloor = thread.newLastFloor;
                thread.newLastFloor = -1;
                setTimeout(function (){loopWatchThread(thread, MaxPage)}, WatchInterval);
            }
        } else {
            thread.lastFloor = thread.newLastFloor;
            thread.newLastFloor = -1;
            setTimeout(function (){loopWatchThread(thread, MaxPage)}, WatchInterval);
        }
    }
}

由于要处理向前翻页的情况,所以有点复杂。简单来说就是检查最后帖子的最后一页,然后从最后一个帖子开始向前遍历,直到碰到目前已经检查过的帖子数为止。检查完一遍之后通过 setTimeout 让 loopWatchThread 在 WatchInterval 时间之后自动再被调用,也就实现了定时监视了。

整个框架大概就是这个样子,其实可以看到这可以比较容易扩展为一个更有趣的 notification 系统,就是对于新贴的通知,可以在内部做一些诸如插件之类的,比如,每次检测到 quark 发的贴,就回一个“quark 是坏人”;或者每次看到前方有整,就奋力向前之类的;或者可以做一些自动化一点的东西,类似于机器人,比如看到有人询问问题,就去网上搜索答案,并给出结果(GM 里允许跨 domain 做 AJAX Request 的)之类的。

另外,其实如果 Firefox 自身提供了比较好的 notification 接口的话(在有新扩展更新等时候它会弹出 notification ,不过那个看起来比较笨,而且不知道有没有接口可以调用),完全可以脱离 Growl 。目前的效果嘛,如本文一开始的截图中所示,Growl 可以有主题选择,自带的主题似乎就这个比较好看一点。感觉还不错,这样就不用老是等 98 缓慢地刷新了,看到有感兴趣的帖子就过去回复一下。

不过,Growl for Windows 也有诸多不和谐的地方,比如它的缩写太恶心(痛恨啊!今天科学社会主义考试,我还忍不住痛斥了一下这个东西 -.-),还有 notification 弹出的位置不能设定,并且占用了 Alt+X 键(这还让我怎么用 Emacs ?虽然在 Windows 下用 Emacs 几乎主要就用来写 LaTeX 了),且不能设置为其他键或者禁用,总的来说可定制性约等于 0 (我听说 Mac 下的软件几乎都是这样的? 😯 )。

不过,总的来说,还是凑合。完整的代码可以从这里下载。 🙂 还有就是,GM 脚本调试起来还真是痛苦啊……

3 comments to Growl for Windows meets GreaseMonkey

  • Firefox本身是有Notification的,就像发现新版本扩展或者是下载全部完成时弹出的框框一样。但是访问这个需要一定特权,我现在还没有在GM里试过。

    有一个问题:用GM这样做是不是至少要打开一个cc98页面才可以用,两个或者以上的98页面会不会重复Notify?

  • @quark
    如果你打开那个 user.js 看,会发现在 @include 里那里只设定了 98 的主页(不用通配符),然后打开一个主页在旁边并把标签锁定了就 OK 了,那个页面负责监视,而其他的 98 页面不会出现干扰。 🙂

  • This is great! It really shows me where to expand my blog. I think that sometime in the future I might try to write a book to go along with my blog, but we will see…Good post with useful tips and ideas