加入收藏 | 设为首页 | 会员中心 | 我要投稿 李大同 (https://www.lidatong.com.cn/)- 科技、建站、经验、云计算、5G、大数据,站长网!
当前位置: 首页 > 大数据 > 正文

使用 Groovy 合并 MSN 聊天记录

发布时间:2020-12-14 17:03:31 所属栏目:大数据 来源:网络整理
导读:做挨踢的一般都有无数台电脑,一会儿在服务器上登录,一会儿又到工作站,结果就是散落一地的 MSN 聊天记录。偏偏这些记录还有防身的作用(有些精英就喜欢用 MSN 下达指令,还特别喜欢在事后矢口否认……),所以,在适当的时候进行备份总是不错的。 但是,MS

做挨踢的一般都有无数台电脑,一会儿在服务器上登录,一会儿又到工作站,结果就是散落一地的 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,Date,DateTime]
??? Text[Style]
??? Application
??? File
??? From
????? User[FriendlyName]
? Message[SessionID,Date]
??? Text[Style]
??? To
????? User[FriendlyName]
??? From
????? User[FriendlyName]
? Invitation[SessionID,DateTime]
??? Text[Style]
??? Application
??? File
??? From
????? User[FriendlyName]
? Join[SessionID,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 = []
   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,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,又在记录中使用了本机时间,所以,理论上我们只能“猜测”两段对话是否相同。反正重复的结果并不严重,所以我就把它忽略了。

(编辑:李大同)

【声明】本站内容均来自网络,其相关言论仅代表作者个人观点,不代表本站立场。若无意侵犯到您的权利,请及时与联系站长删除相关内容!

    推荐文章
      热点阅读