使用 Groovy 合并 MSN 聊天记录
做挨踢的一般都有无数台电脑,一会儿在服务器上登录,一会儿又到工作站,结果就是散落一地的 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 以及计划任务的配合,可以完全自动化的定期合并、同步所有机器上的聊天记录,方法如下:
- 将本机的默认聊天记录目录同步到 Live Mesh。(千万不要和其它的机器共享,否则会相互覆盖)
- 在 Live Mesh 上建立一个同步到 SkyDriver 的目录,和所有机器共享,用于存放合并后的记录。
- 在每台机器上均建立一个计划任务,其步骤包括
- 将本机默认目录的内容合并至共享目录
- 备份默认目录的文件(我习惯用 Winrar 的命令行软件来操作),然后用合并后的文件覆盖。注意第一步和第二步里从哪个目录合并到哪个目录其实不重要,只要保证合并完后两个目录均为合并后的内容即可
- 设置一个自动运行计划。(如果网速非常快非常稳定,那么自动运行时间不太重要,稍稍错开就可以。对于我这种超细小水管的,半个月合并一次,每台机器的合并时间错开几天就可以了)
PS III. 也许更好的办法是合并一次,然后在某台服务器上二十四小时登录,那么这台服务器上的记录以后就会是最全的。问题在于总有宕机的时候,而且用手机MSN登录的话服务器就会被踢下线来……
最后提下这个脚本的缺点:
- 格式太死:目前就支持那么五种消息类型,微软一更新就可能要修改代码
- 无法识别多点登录下的重复对话:如果不同登录点的网络状况都很好,那么没有问题,不会发生重复。但是如果有哪边一会儿上一会儿下的,那么不同的登录点的会话数量会不同。由于微软并没有在每个消息的记录上提供GUID,又在记录中使用了本机时间,所以,理论上我们只能“猜测”两段对话是否相同。反正重复的结果并不严重,所以我就把它忽略了。
使用 Groovy 合并 MSN 聊天记录相关推荐
- QQ合并的聊天记录可以通过什么方式转换成链接,点开链接就能看合并的聊天记录?
合并记录到底是干嘛用的,我想当你点进这篇文章的时候,心里肯定不是迷茫的那种,应该略知一二吧, 不是那种一问三不知的小白,请看上图,你正在看的是什么东东呢? 我看到网络上很多网友提问:(以下是转介于网友 ...
- JavaEye博客备份脚本订制版
今天[url=http://robbin.iteye.com/]Robbin[/url]在[url=http://www.iteye.com/wiki/JavaEye/2104]如何批量导出JavaE ...
- 自己编写的MSN历史记录合并工具
!!News: 可以合并整个MSN目录了. 并正式提出版本号:MSNChatHistoriesCombinator-v0.3.12.0700 为什么要写它 大家可能正在使用MSN Messenger, ...
- 用DiskGenius恢复分区及文件的方法
用DiskGenius恢复分区及文件的方法 作者: DiskGenius 2009年5月6日 最近在email及论坛中发现很多朋友在分区或文件丢失后恢复数据时不得要领,不知道如何操作才能恢复数据,甚至 ...
- 【长篇连载】桌面管理演义 第六回 违规言论别乱发 访问控制把你抓
上回书说到,小王拿着新买的U盘想拷贝点东西,结果被桌管系统给拒绝了,小王又一次见识到了桌管系统强大的功能,作为电脑高手,他还是挺不爽的.这天,他把手头的工作做完,闲来无事就跑到天涯论坛上面逛逛,顺便发 ...
- 【转】其实Unix很简单
其实Unix很简单 陈皓 很多编程的朋友都在网上问我这样的几个问题,Unix怎么学?Unix怎么这么难?如何才能学好?并且让我给他们一些学好Unix的经验.在绝大多数时候,我发现问这些问题的朋友都 ...
- 学习Unix其实就这样简单
很多编程的朋友都在网上问我这样的几个问题,Unix怎么学?Unix怎么这么难?如何才能学好?并且让我给他们一些学好Unix的经验.在绝大多数时候,我发现问这些问题的朋友都有两个特点: 1)对Unix有 ...
- U盘在手,忘记任何密码都可找回!!
U盘在手,忘记任何密码都可找回!! 一只U盘帮您解决忘记:CMOS开机密码.CMOS进入密码.操作系统管理员密码.用户密码. OFFIC系列文件密码,WPS系列文件密码,QQ.MSN聊天记录密码,PD ...
- 经过本人盘点与细数,总结出个人云存储与传统网盘五大差别
2012年伊始,国内互联网火热的话题莫过于个人云存储.金山快盘.E8共享.5A资源网.盛大EverBox陆续推出了个人云存储产品,希望能趁移动互联网火爆之势迅速积聚用户.目前在各家公布的个人云存储市场 ...
- 专家教你如何使用google
1. Google搜索技巧(11):提高精确度的"in" In-系列搜索指令是Google搜索中最重要的"位置关键词"查找方式,通过intitle/inurl/ ...
最新文章
- 安装mysql总是未响应状态_求助啊 WIN7下安装mysql出问题 老是说未响应~!!
- EasyUI环境搭建与入门基础语法
- UPX脱壳全程分析(转)
- MySQL JDBC驱动程序如何处理准备好的语句
- NavMeshAgent 动态加载障碍物
- C++强化之路之线程池开发整体框架(二)
- 使用集合映射和关联关系映射_使用R进行基因ID映射
- python读取数据库中指定内容_python如何用正则表达式读取对应字段写入数据库中?...
- 阿里云-对象存储 OSS > 开发指南 > 基本概念
- linux服务器云防火墙配置文件,Linux云服务器防火墙配置之Firewalld
- python将txt文档中的内容按字母顺序进行排序,并存入txt中
- java给链表赋值_java链表的各种操作
- kali虚拟机连接外网VMnet8显示无分配网络权限
- SQl语句学习专题(转)
- 电影影视网站对接微信公众号 日引流500+的实例教学
- 【秒杀】一、系统设计要点,从卖病鹅说起
- 小学生预习能力培养的策略和方法研究	开题报告
- 【蓝桥杯】《试题 基础练习 特殊回文数》详解
- Android Home键按键事件监听
- 快速对比两张工作表数据差异——《超级处理器》应用