做挨踢的一般都有无数台电脑,一会儿在服务器上登录,一会儿又到工作站,结果就是散落一地的 MSN 聊天记录。偏偏这些记录还有防身的作用(有些精英就喜欢用 MSN 下达指令,还特别喜欢在事后矢口否认……),所以,在适当的时候进行备份总是不错的。

但是,MSN 并没有提供日志的导入、合并功能,我只得求助于第三方。

PS. 我想有人会推荐“有备”之类的工具。恩,也许它真的很好很强大,但是一来我不希望为了修指甲而配备一把瑞士军刀,二来我也不大信得过这类软件……理由不解释。

Google 之下,发现了一个 Java 版本的记录合并工具 MSNHistoryCombiner (jungleford)。下载之,细查源码。代码不是我喜欢的类型,有很多其实什么也不做的 Exception。(实际上真正烦的是一大堆的 swing 代码,我虽然是 swing 的爱好者,但是在不需要 GUI 的情况下,我还是希望能简单一点)

Combiner 当然可以完成大部分工作,但是这里有几个小小的问题:

  • 无法自动化运行:每次都必须通过 GUI 来操作,而且要手动输入参数
  • 最近的几个 MSN 版本是支持多点登录的,这种情况下合并记录会产生重复(这个问题我也解决不了,原因见后文)
  • 视频邀请、文件传输、群聊时大家的登录登出记录不是很完善

所以我决定重新发明个轮子。(好吧,最重要的问题在于我运行上面这个程序时报错了……)

首先要考察的是 MSN 记录的格式。通过肉眼观察,我看到了一大堆的, 之类的标签,格式算是比较简单的。很显然,MS 出品的缘故我并不奢望能找到一个官方的记录格式说明。所以,唯一可行的是写一个脚本大致了解下有哪些常用的参数。计划如下:遍历我所有的 MSN 记录,列举所有的标签和参数名称,然后以树形结构打印出来:

   1: class Node {
   2:     String name = '*** Root ***'
   3:     Node parent
   4:     Set attrs
   5:     Set children = []
   6:     int level
   7:     
   8:     def Node(node, Node parent = null, int level = 0) {
   9:         this.level = level
  10:         this.parent = parent
  11:         if(node) {
  12:             name = node.name()
  13:             attrs = node.attributes().keySet()
  14:             node.children().each { merge(new Node(it, this, level + 1)) }   
  15:         }  
  16:     }
  17:         
  18:     void merge(Node other) {
  19:         if(children.contains(other)) {
  20:             def child = children.find { it == other }
  21:             child.attrs += other.attrs
  22:             other.children.each { child.merge(it) }
  23:         } else children << other
  24:     }
  25:     
  26:     private getIndent() { ' ' * 2 * level }
  27:         
  28:     String toString() {
  29:         """$indent$name${ attrs ? attrs : '' }${ children ? '/n' + children.collect { it.toString() }.join("/n") : '' }"""
  30:     }    
  31:     
  32:     boolean equals(obj) { obj && obj instanceof Node && obj.name == name }    
  33:     int hashCode() { name.hashCode() }
  34: }
  35:  
  36: def folder = new File('/home/hiarcs/deep_crazy4057207345/History')
  37: def node = new Node(null)
  38: folder.eachFileMatch(~/.*/.xml/) { node.merge(new Node(new XmlSlurper().parse(it), node)) }
  39: println node

很直接了当的三十九行代码。(很想知道用 Java 写的话需要几页)

运行脚本,得到以下结果

*** Root ***
Log[LastSessionID, FirstSessionID]
  Leave[SessionID, Time, DateTime, Date]
    User[FriendlyName]
    Text[Style]
  InvitationResponse[SessionID, Time, Date, DateTime]
    Text[Style]
    Application
    File
    From
      User[FriendlyName]
  Message[SessionID, Time, DateTime, Date]
    Text[Style]
    To
      User[FriendlyName]
    From
      User[FriendlyName]
  Invitation[SessionID, Time, Date, DateTime]
    Text[Style]
    Application
    File
    From
      User[FriendlyName]
  Join[SessionID, Time, DateTime, Date]
    User[FriendlyName]
    Text[Style]

简单分析的话,就是每个记录文件都是一个 Log,其中包含了五种不同的消息类型。

PS II. 前面这个脚本其实蛮有用的,可以用来在没有定义文件的情况下考察简单 xml 文件的结构。但是它也有明显的缺点:无法显示标签之间的是否是互斥、依存等关系,只能了解到它曾经出现过。但是对多数情况而言,这个缺点也不是太了不起……

话说虽然 xml 很讨厌,但是这种简明树状结构还是非常容易建模的:通常每一层都对应一个类就可以了

   1: import groovy.xml.*
   2: import java.text.*
   3:  
   4: // History对应包含着多个MSN日志文件(xml)的目录。
   5: class History {
   6:     final folder, logs = []
   7:     
   8:     def History(folder) {
   9:         this.folder = folder
  10:         folder.eachFileMatch(~/.*/.xml/) { logs << new Log(it) }     
  11:     }
  12:     
  13:     def merge(other) {
  14:         other.logs.each { log ->
  15:             def bak = logs.find { it.account == log.account }
  16:             if(bak) { bak.merge(log) } else logs << log
  17:         }
  18:     }
  19:     
  20:     def saveTo(folder) {
  21:         if(!folder.exists()) folder.mkdir()
  22:         assert folder.isDirectory()
  23:         logs.each { it.saveTo(folder) }
  24:     }    
  25:     def save() { saveTo(folder) }
  26: }
  27:  
  28: class Util {
  29:     static builder = {
  30:         def builder = new StreamingMarkupBuilder()
  31:         builder.encoding = 'UTF-8'
  32:         builder
  33:     }
  34:     
  35:     static export(binding) {        
  36:         def builder = builder()
  37:         builder.bind(binding)
  38:     }
  39: }
  40:  
  41: // Log对应单个的xml文件
  42: class Log {
  43:     final account // 文件名(hash过的MSN帐号)
  44:     def sessions
  45:     def Log(file) {
  46:         account = file.name - '.xml'
  47:         sessions = groupSessions(new XmlSlurper().parse(file))
  48:     }
  49:     
  50:     String export() {
  51:         def log = {
  52:             mkp.xmlDeclaration()
  53:             mkp.pi('xml-stylesheet': "type='text/xsl' href='MessageLog.xsl'")
  54:             Log(FirstSessionID: 1, LastSessionID: sessions.size()) {
  55:                 sessions.sort().eachWithIndex { session, index ->
  56:                     unescaped << session.export(index)
  57:                 }
  58:             }
  59:         }
  60:         Util.export(log)
  61:     }
  62:     
  63:     def merge(log) {
  64:         assert log.account == account
  65:         log.sessions.each { if(!sessions.contains(it)) sessions << it }   
  66:     }
  67:     
  68:     def saveTo(folder) {
  69:         new File(folder, "${account}.xml").write(export())
  70:     }
  71:     
  72:     //这里把文件中解析到不同的session。Session仅仅影响到日志的显示格式(背景颜色),不分也没有关系
  73:     private groupSessions(node) {
  74:         def list = []
  75:         node.children().each { list << it }      
  76:         list = list.groupBy { it.@SessionID.text() }.values()
  77:         //这里仅仅处理5种不同的实体类型,可能存在其它的类型
  78:         list.collect { nodes ->
  79:             new Session(sections: nodes.collect {                
  80:                 switch(it.name()) {
  81:                     case ['Leave', 'Join']:
  82:                         new Participation(it)
  83:                         break
  84:                     case ['InvitationResponse', 'Invitation']:
  85:                         new Invitation(it)
  86:                         break
  87:                     case 'Message':
  88:                         new Message(it)
  89:                         break
  90:                     default:
  91:                         throw new IllegalStateException("Unexpected name: ${ it.name() }")
  92:                 }
  93:             })
  94:         }
  95:     }  
  96: }
  97:  
  98: //一组对话(也就是开着聊天窗口不断聊,只要不关闭窗口或断线就算一个会话
  99: class Session implements Comparable {
 100:     def sections
 101:     
 102:     def export(index) { sections.collect { it.export(index) }.join() }  
 103:     
 104:     // Session的日期时间即第一个消息的时间,仅供比较排序用
 105:     def getDate() { sections ? sections[0].date : null }
 106:     
 107:     int compareTo(other) { date?.compareTo(other?.date) }    
 108:     
 109:     // 这里的判断比较阳春。只有在两个Session的结构完全相同的情况下才返回true。但是在最近几个版本的MSN里都有
 110:     // 多点登录的功能。如果一台机器始终处于登录状态,另一台机器则是断断续续的登录,则同一段会话在两台机器上会
 111:     // 被记录为不同的Session。通过时间比对是不现实的,因为MSN记录的是本机时间,所以误差会影响判断。只能结合
 112:     // 时间和内容猜测两组Session是否对应着同一段会话,但计算成本会很高,就备份MSN日志这样的应用来说犯不着。
 113:     boolean equals(obj) { obj && obj instanceof Session && obj.sections == sections }
 114:     int hashCode() { sections.hashCode() }    
 115: }
 116:  
 117: //对应每一条具体的消息片段
 118: abstract class Section {
 119:     def node, date, text, name
 120:     def init(node) {
 121:         this.node = node
 122:         name = node.name()
 123:         date = new LogDate(node.@DateTime.text())
 124:         text = new Text(node.Text.text(), node.Text.@Style.text())
 125:     }
 126:     
 127:     def prefix = ''
 128:     def suffix = ''
 129:       
 130:     def export(index) {
 131:         def section = {
 132:             "$name"(Date: date.date, Time: date.time, DateTime: date.datetime, SessionID: index + 1) {
 133:                 unescaped << prefix
 134:                 Text(Style: text.style) { out << text.content }
 135:                 unescaped << suffix
 136:             }
 137:         }
 138:         Util.export(section)
 139:     }
 140:     
 141:     boolean equals(obj) { obj && obj instanceof Section && obj.name == name && obj.text == text }
 142:     int hashCode() { text.hashCode() }
 143: }
 144:  
 145: // 用于表述日志中的日期格式
 146: // 本来这个类没有那么复杂,但是看到其中的DateTime格式后,我强烈怀疑MSN的代码有问题,应该是格式字符串给弄错了
 147: // 长日期格式的最后应该是时区,但是日志中却是代表时区格式的'Z'字符,应该是MS的程序员多写了一对单引号吧。
 148: // 最初我是拿DateTime的字符串直接截取成Date和Time,结果发现时区偏移了8小时……于是一冲动就玩了把日期解析。
 149: // 其实简单的做法应该是直接从xml里读取全部的三个参数,那样完全可以少写7行代码的:(
 150: // 安慰自己下,这样的话至少可以检测出日志里错误的日期格式啊
 151: class LogDate implements Comparable {
 152:     private static final timezone = TimeZone.getTimeZone('Asia/Shanghai')
 153:     private static final dtf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
 154:     private static final df = new SimpleDateFormat('yyyy-MM-dd')
 155:     private static final tf = new SimpleDateFormat('HH:mm:ss')
 156:     final String datetime, date, time
 157:     def LogDate(datetime) {
 158:         this.datetime = datetime
 159:         def d = dtf.parse(datetime)  
 160:         d = new Date(d.time - timezone.getOffset(d.time))
 161:         date = df.format(d)
 162:         time = tf.format(d)      
 163:     }
 164:     
 165:     int compareTo(other) { datetime.compareTo(other?.datetime) }
 166: }
 167:  
 168: class Text {
 169:     final content, style
 170:     def Text(content, style) {
 171:         this.content = content
 172:         this.style = style
 173:     }
 174:     boolean equals(obj) {
 175:         obj && obj instanceof Text && content == obj.content && style == obj.style
 176:     }
 177:     int hashCode() { content.hashCode() }
 178: }
 179:  
 180: class User {
 181:     final friendlyName
 182:     def User(friendlyName) {
 183:         this.friendlyName = friendlyName
 184:     }    
 185:     def export() { Util.export({ User(FriendlyName: friendlyName) }) }
 186: }
 187:  
 188: class UserList {
 189:     final type, users = []
 190:     def UserList(node) {
 191:         type = node.name()
 192:         node.children().each {
 193:             if(it.name() != 'User') throw new IllegalStateException("Unexpected name: ${it.name}")
 194:             users << new User(it.@FriendlyName.text())
 195:         }
 196:     }
 197:     
 198:     def export() { """<$type>${users.collect{ it.export() }.join()}""" }
 199: }
 200:  
 201: // 群聊的时候用户加入和离开的信息
 202: class Participation extends Section {
 203:     def user
 204:     def Participation(node) {
 205:         init(node)
 206:         user = new User(node.User.@FriendlyName.text())
 207:     }
 208:     
 209:     String getPrefix() { user.export() }
 210: }
 211:  
 212: class Message extends Section {
 213:     def from, to
 214:     def Message(node) {
 215:         init(node)
 216:         node.children().each { child ->
 217:             switch(child.name()) {
 218:                 case 'Text': break
 219:                 case 'To': to = new UserList(child); break
 220:                 case 'From': from = new UserList(child); break
 221:                 default: throw new IllegalStateException("Unexpected name: ${child.name}")
 222:             }    
 223:         }    
 224:     } 
 225:     
 226:     String getSuffix() { "${to.export()}${from.export()}" }
 227: }
 228:  
 229: // 文件邀请、视频邀请的记录
 230: class Invitation extends Section {
 231:     def from, contents = [:]
 232:     def Invitation(node) {
 233:         init(node)        
 234:         node.children().each { child ->
 235:             switch(child.name()) {
 236:                 case 'Text': break
 237:                 case 'From': from = new UserList(child); break
 238:                 default: contents.put(child.name(), child.text())
 239:             }
 240:         }        
 241:     }
 242:     
 243:     String getSuffix() {
 244:         """${contents.collect { key, value -> Util.export({ "$key" { out << value } }) }.join()}${from.export()}"""
 245:     }
 246: }
 247:  
 248: //-----------------------------------------------------------------
 249: // 待整合的文件目录(该目录下的文件不会发生变化)
 250: path = '/home/hiarcs/deep_crazy4057207345/History'
 251: // 整合目标(对应的文件将变大)
 252: backup = new History(new File('/home/hiarcs/msn'))
 253:  
 254: folder = new File(path)
 255: history = new History(folder)
 256:  
 257: backup.merge(history)
 258: backup.save()
 259: 'Done'

运行这个脚本即可完成目录级的合并。关键在于,通过和 Live Mesh 以及计划任务的配合,可以完全自动化的定期合并、同步所有机器上的聊天记录,方法如下:

  1. 将本机的默认聊天记录目录同步到 Live Mesh。(千万不要和其它的机器共享,否则会相互覆盖)
  2. 在 Live Mesh 上建立一个同步到 SkyDriver 的目录,和所有机器共享,用于存放合并后的记录。
  3. 在每台机器上均建立一个计划任务,其步骤包括
    1. 将本机默认目录的内容合并至共享目录
    2. 备份默认目录的文件(我习惯用 Winrar 的命令行软件来操作),然后用合并后的文件覆盖。注意第一步和第二步里从哪个目录合并到哪个目录其实不重要,只要保证合并完后两个目录均为合并后的内容即可
    3. 设置一个自动运行计划。(如果网速非常快非常稳定,那么自动运行时间不太重要,稍稍错开就可以。对于我这种超细小水管的,半个月合并一次,每台机器的合并时间错开几天就可以了)

PS III. 也许更好的办法是合并一次,然后在某台服务器上二十四小时登录,那么这台服务器上的记录以后就会是最全的。问题在于总有宕机的时候,而且用手机MSN登录的话服务器就会被踢下线来……

最后提下这个脚本的缺点:

  • 格式太死:目前就支持那么五种消息类型,微软一更新就可能要修改代码
  • 无法识别多点登录下的重复对话:如果不同登录点的网络状况都很好,那么没有问题,不会发生重复。但是如果有哪边一会儿上一会儿下的,那么不同的登录点的会话数量会不同。由于微软并没有在每个消息的记录上提供GUID,又在记录中使用了本机时间,所以,理论上我们只能“猜测”两段对话是否相同。反正重复的结果并不严重,所以我就把它忽略了。

使用 Groovy 合并 MSN 聊天记录相关推荐

  1. QQ合并的聊天记录可以通过什么方式转换成链接,点开链接就能看合并的聊天记录?

    合并记录到底是干嘛用的,我想当你点进这篇文章的时候,心里肯定不是迷茫的那种,应该略知一二吧, 不是那种一问三不知的小白,请看上图,你正在看的是什么东东呢? 我看到网络上很多网友提问:(以下是转介于网友 ...

  2. JavaEye博客备份脚本订制版

    今天[url=http://robbin.iteye.com/]Robbin[/url]在[url=http://www.iteye.com/wiki/JavaEye/2104]如何批量导出JavaE ...

  3. 自己编写的MSN历史记录合并工具

    !!News: 可以合并整个MSN目录了. 并正式提出版本号:MSNChatHistoriesCombinator-v0.3.12.0700 为什么要写它 大家可能正在使用MSN Messenger, ...

  4. 用DiskGenius恢复分区及文件的方法

    用DiskGenius恢复分区及文件的方法 作者: DiskGenius 2009年5月6日 最近在email及论坛中发现很多朋友在分区或文件丢失后恢复数据时不得要领,不知道如何操作才能恢复数据,甚至 ...

  5. 【长篇连载】桌面管理演义 第六回 违规言论别乱发 访问控制把你抓

    上回书说到,小王拿着新买的U盘想拷贝点东西,结果被桌管系统给拒绝了,小王又一次见识到了桌管系统强大的功能,作为电脑高手,他还是挺不爽的.这天,他把手头的工作做完,闲来无事就跑到天涯论坛上面逛逛,顺便发 ...

  6. 【转】其实Unix很简单

    其实Unix很简单   陈皓 很多编程的朋友都在网上问我这样的几个问题,Unix怎么学?Unix怎么这么难?如何才能学好?并且让我给他们一些学好Unix的经验.在绝大多数时候,我发现问这些问题的朋友都 ...

  7. 学习Unix其实就这样简单

    很多编程的朋友都在网上问我这样的几个问题,Unix怎么学?Unix怎么这么难?如何才能学好?并且让我给他们一些学好Unix的经验.在绝大多数时候,我发现问这些问题的朋友都有两个特点: 1)对Unix有 ...

  8. U盘在手,忘记任何密码都可找回!!

    U盘在手,忘记任何密码都可找回!! 一只U盘帮您解决忘记:CMOS开机密码.CMOS进入密码.操作系统管理员密码.用户密码. OFFIC系列文件密码,WPS系列文件密码,QQ.MSN聊天记录密码,PD ...

  9. 经过本人盘点与细数,总结出个人云存储与传统网盘五大差别

    2012年伊始,国内互联网火热的话题莫过于个人云存储.金山快盘.E8共享.5A资源网.盛大EverBox陆续推出了个人云存储产品,希望能趁移动互联网火爆之势迅速积聚用户.目前在各家公布的个人云存储市场 ...

  10. 专家教你如何使用google

    1. Google搜索技巧(11):提高精确度的"in" In-系列搜索指令是Google搜索中最重要的"位置关键词"查找方式,通过intitle/inurl/ ...

最新文章

  1. 安装mysql总是未响应状态_求助啊 WIN7下安装mysql出问题 老是说未响应~!!
  2. EasyUI环境搭建与入门基础语法
  3. UPX脱壳全程分析(转)
  4. MySQL JDBC驱动程序如何处理准备好的语句
  5. NavMeshAgent 动态加载障碍物
  6. C++强化之路之线程池开发整体框架(二)
  7. 使用集合映射和关联关系映射_使用R进行基因ID映射
  8. python读取数据库中指定内容_python如何用正则表达式读取对应字段写入数据库中?...
  9. 阿里云-对象存储 OSS > 开发指南 > 基本概念
  10. linux服务器云防火墙配置文件,Linux云服务器防火墙配置之Firewalld
  11. python将txt文档中的内容按字母顺序进行排序,并存入txt中
  12. java给链表赋值_java链表的各种操作
  13. kali虚拟机连接外网VMnet8显示无分配网络权限
  14. SQl语句学习专题(转)
  15. 电影影视网站对接微信公众号 日引流500+的实例教学
  16. 【秒杀】一、系统设计要点,从卖病鹅说起
  17. 小学生预习能力培养的策略和方法研究 开题报告
  18. 【蓝桥杯】《试题 基础练习 特殊回文数》详解
  19. Android Home键按键事件监听
  20. 快速对比两张工作表数据差异——《超级处理器》应用

热门文章

  1. 英文文献很难找,哪里可以找英文文献呢?
  2. win8.1中文版开启远程桌面
  3. 关于Log4j 1.x 升级Log4j 2.x 那些事
  4. OpenGL 高质量纹理过滤的实例
  5. Tokio教程之深入异步
  6. 撕逼利器——批判性思维
  7. java正方形个圆形面积_JAVA--接口练习(求正方形和圆的周长、面积)
  8. 软件开发项目人员配置
  9. 淘宝运营之:店铺信用分计算规则
  10. 钉钉小程序内嵌web网页