`
RednaxelaFX
  • 浏览: 3016351 次
  • 性别: Icon_minigender_1
  • 来自: 海外
社区版块
存档分类
最新评论

合并MSN聊天记录的脚本(探索中)

    博客分类:
  • Ruby
阅读更多
上周回了一趟老家,没网上,只好做些不用上线的事。正好找了点时间来写合并MSN(Windows Live Messenger)的聊天记录的脚本。

MSN的聊天记录以XML文件的形式保存,默认保存在%My Documents%\My Received Files\username\History里。中文系统的话是默认保存在%我的文档%\我接受到的文件\username\历史记录里。保存路径可以在登录MSN后设置。
一般我在同一台机器上重装系统的话,会把聊天记录的目录转移到新装的系统上。但是我也经常要用不同的机器,偶尔也需要在不是我的机器上登录MSN。这么一来,聊天记录就散得到处都是了。每次整理数据的时候都想解决一下这个问题,不过每次都是懒了……

既然是XML,处理起来应该是非常方便的——不用自己从lexer和parser开始写解析用的程序。那么来看看一个MSN的聊天记录文件大概长什么样:
<?xml version="1.0"?>
<?xml-stylesheet type='text/xsl' href='MessageLog.xsl'?>
<Log FirstSessionID="1" LastSessionID="1"><Message Date="5/31/2009" Time="7:50:34 AM" DateTime="2009-05-30T23:50:34.699Z" SessionID="1"><From><User FriendlyName="三工"/></From><To><User FriendlyName="Ravenex"/></To><Text Style="color:windowtext; ">http://www.engadget.com/2009/05/30/sonys-psp-go-leaks-out-before-e3-is-obviously-a-go/</Text></Message></Log>

可以看到该文件引用了一个XSLT文件来做渲染。事实上在MSN里浏览聊天记录的时候显示的就是通过该XSLT转换过的XML记录。
这个XML文件的根节点是Log,属性包括FirstSessionID和LastSessionID两个。Log下面的子节点主要是一个或多个Message节点,其中的Session属性表示会话序号,前面提到根节点的两个属性对应文件中所有Session值的状况;也可能存在另外两种节点,下文会提到。
合并记录的关键就在于那些Session值,其它内容都不用修改,直接复制过来就行。脚本应该根据日期正确的把聊天记录按顺序排起来,并重新计算各个Message(和另外两种节点)中的Session值,然后更新Log的FirstSessionID/LastSessionID,最后写出文件。

用Ruby来处理XML文件,我还是习惯性选择用Nokogiri来做。这里用的版本是Nokogiri 1.2.3。通过Nokogiri.parse得到一个Document对象doc后,通过doc/'Log/Message'就可以得到根节点下的所有Message节点了。我以为根节点下只有Message节点,试着写了个实验用脚本,发现合并了之后结果居然比其中一个源还要小,肯定出问题了。

于是就另外写了个脚本来看聊天记录的XML文件里根节点下到底有哪些类型的节点:
extract_node_names.rb:
require 'rubygems'
require 'nokogiri'

fname = ARGV[0]
doc = Nokogiri.parse File.read(fname)
log = doc.root
children = log.children

names = children.inject({}) do |acc, e|
  n = e.node_name
  acc[n] ||= e
  acc
end

p names.keys
puts
names.keys.each do |k|
  puts "#{k}:"
  puts names[k].to_xml('gbk'), ''
end


对某个文件运行该脚本,结果是:
["Message", "Invitation", "InvitationResponse"]

Message:
<Message Date="7/14/2009" Time="9:23:10 PM" DateTime="2009-07-14T13:23:10.898Z"
SessionID="1">
  <From>
    <User FriendlyName="Hg"/>
  </From>
  <To>
    <User FriendlyName="RednaxelaFX"/>
  </To>
  <Text Style="font-family:Segoe UI; color:#000000; ">现在有验证机制了么</Text>
</Message>

Invitation:
<Invitation Date="7/14/2009" Time="9:43:34 PM" DateTime="2009-07-14T13:43:34.678
Z" SessionID="1">
  <From>
    <User FriendlyName="RednaxelaFX"/>
  </From>
  <File>C:\Documents and Settings\RednaxelaFX\Desktop\amazon090714.txt</File>
  <Text Style="color:#545454; ">RednaxelaFX sends C:\Documents and Settings\RednaxelaFX\Desktop\New File.txt</Text>
</Invitation>

InvitationResponse:
<InvitationResponse Date="7/14/2009" Time="9:43:49 PM" DateTime="2009-07-14T13:4
3:49.529Z" SessionID="1">
  <From>
    <User FriendlyName="Hg"/>
  </From>
  <File>C:\Documents and Settings\RednaxelaFX\Desktop\amazon090714.txt</File>
  <Text Style="color:#545454; ">Transfer of "New File.txt" is complete.</Text>
</InvitationResponse>

原来除了Message节点外还有Invitation和InvitationResponse两种节点,对应传输文件的信息。或许我还没碰到所有类型的节点……不过只要这些节点有Session和DateTime属性就能用同样的方式去处理,所以倒不用怎么担心。

OK,那就写个简单的脚本来解决这个合并记录的问题:
cat_msn_logs.rb:
require 'rubygems'
require 'nokogiri'
require 'fileutils'

# Convert a Nokogiri::XML::Document into
# an array of arrays, grouped by SessionID.
# The inner arrays begin with the DateTime of
# their first Element, used later for sorting.
def group_log_by_sid(xml_log)
  xml_log.root.children.inject({}) {|acc, node|
    sid = node['SessionID']
    (acc[sid] ||= [ DateTime.parse(node['DateTime']) ]) << node
    acc
  }.values
end

# Join two arrays of Element groups into one,
# sorted by DateTime, and removing redundant
# entries.
def join_groups(grp1, grp2)
  groups = (grp1 + grp2).
    sort_by    {|g| g[0] }.
    inject([]) {|acc, g|
      (acc.empty? || acc.last[0] != g[0]) ?
      acc << g : acc }.
    map        {|g| g.shift; g }
  
  # fix SessionIDs
  groups.each_with_index do |grp, idx|
    sid = (idx + 1).to_s
    grp.each {|n| n['SessionID'] = sid }
  end
end

# Save a Nokogiri::XML::Document to file,
# with no formatting, and UTF-8 encoding.
def save_log(xml_log, fpath)
  File.open(fpath, 'w') do |f|
    # need to pass in save_options to
    # strip excessive whitespace/formatting
    xml_log.write_to(
      f,       # io
      'utf-8', # encoding
      Nokogiri::XML::Node::SaveOptions::AS_XML)
  end
end

# Join the two designated MSN logs into one.
def join_logs(fname, src_dir1, src_dir2, dest_dir)
  log1, log2 = [ src_dir1, src_dir2 ].map do |d|
    File.open(File.join(d, fname), 'r') do |f|
      Nokogiri.parse f
    end
  end
  
  nodes = join_groups(*(
    [ log1, log2 ].map {|log| group_log_by_sid log }))
  
  root = log1.root
  root['FirstSessionID'] = '1'
  root['LastSessionID']  = nodes.length.to_s
  root.inner_html = ''
  nodes.flatten.each {|n| root.add_child n }
  
  save_log log1, File.join(dest_dir, fname)
end

# command-line arguments:
# src_dir1,
# src_dir2,
# dest_dir (optional, defaults to src_dir1)
#
# variables to keep track of:
# src_dir1 : String
# src_dir2 : String
# dest_dir : String
# fname    : String
if __FILE__ == $0
  src_dir1, src_dir2, dest_dir = ARGV.map {|p| File.expand_path p }
  dest_dir ||= src_dir1
  FileUtils.makedirs dest_dir
  
  # check if files with the same name exist in both dirs,
  src_entries1, src_entries2 = [ src_dir1, src_dir2 ].map do |d|
    Dir.entries(d).grep(/\.xml/i)
  end
  in_both_dirs = src_entries1 & src_entries2
  [
    [ src_dir1, src_entries1 ],
    [ src_dir2, src_entries2 ]
  ].each do |g|
    src_dir = g[0]
    entries = g[1]
    unless src_dir.downcase == dest_dir.downcase
      (entries - in_both_dirs).each do |f|
        FileUtils.copy_file(
          File.join(src_dir, f),  # src
          File.join(dest_dir, f), # dest
          true                    # preserve
        )
      end
    end
  end
  # disable GC due to memory leak issues in Nokogiri
  GC.start
  GC.disable
  # otherwise, join the logs
  in_both_dirs.each do |f|
    print "processing #{f}..."
    join_logs f, src_dir1, src_dir2, dest_dir
    puts 'ok'
  end
  #GC.enable
end

__END__

# the following code may be used later in refactoring

# not used...
#~ def read_xml_file(fpath)
  #~ Nokogiri.parse File.read(fpath)
#~ end

# not used...
#~ def log_session_range(xml_log)
  #~ root = xml_log.root
  #~ root['FirstSessionID'].to_i..root['LastSessionID'].to_i
#~ end

# not used...
#~ def node_sid(xml_node)
  #~ xml_node['SessionID'].to_i
#~ end

# not used...
#~ def set_node_sid(xml_node, sid)
  #~ xml_node['SessionID'] = sid.to_s
#~ end

# not used...
#~ def node_datetime(xml_node)
  #~ DateTime.parse xml_node['DateTime']
#~ end

edge cases not handled:
/ - Archive(\d{8})\.xml/

(__END__之后的是我用来提醒自己用的东西……请忽略)
我用的Nokogiri 1.2.3看来在包装libxml2的时候什么地方没弄好,我一开始试的时候只要多处理几个XML文件就会出现segfault。我觉得很纳闷,想了些办法后发现是GC过后就出问题。于是干脆暂时把GC禁用掉,那样segfault只会出现在所有XML文件都处理完了之后,也就不影响使用了。我一直怀疑我是不是有什么该调用的清理用方法没调用导致segfault,hmm

除了Nokogiri带来的问题外,在实际使用过程中还发现了一个问题:
我一直以为这些聊天记录的XML文件中Log的FirstSessionID总是1,而LastSessionID跟文件中出现的Session数一样。后来发现原来MSN会在文件超过2MB后开新的文件,并把原来的文件重命名为“original_filename_without_extension - ArchiveYYYYMMDD.xml”的形式。这样,新开的XML文件中FirstSessionID就不是从1开始了。很不幸我的记录里有一个同学的记录就超过了2M,害我手动处理了 = =

我觉得处理新开文件的情况挺麻烦的。首先我得把Archive文件和当前文件中的内容合并到一起,再跟另外一边的源合并到一起。然后要模仿MSN的做法,在写出的时候记录是否达到了2MB,达到则新开个文件继续写。但是怎么记录已写出的文件大小呢?难道我要在重新计算好各节点的Session值之后,添加回到根节点时对每个节点调用Nokogiri::XML::Element#to_s然后数byte数?好烦啊 T T

本来想再把这个问题考虑进去,顺便重构一下再发出来的。想想还是先发个出来收集些建议再修改比较有效率。求改进建议 <(_ _)>
分享到:
评论
6 楼 RednaxelaFX 2009-08-14  
升级到Nokogiri 1.3.3之后更糟糕了……在Windows上用Nokogiri难道就是个错误么 T T
换回Hpricot再试……
5 楼 RednaxelaFX 2009-08-14  
night_stalker 写道
这么说 msn 认识巨大的聊天记录喽? 那不用拆了 ……

不拆应该不影响MSN的正常使用,但是超过1M的记录直接用浏览器打开的时候就已经很卡了(例如说通过IE调用MSXML来打开,它会自动处理XSLT,所以能正常显示出来;FF之类的也行)。所以我还是想按照MSN的方式来拆分,不想留下合并的痕迹。
4 楼 night_stalker 2009-08-14  
RednaxelaFX 写道

先输出大文件再拆,但是“再拆”的时候还是得把文件读进来,解析XML,然后再来……还是回到起点了啊。关键是MSN在拆分聊天记录的时候是不会在Session的中间断开的,这是因为它是在写新记录的时候读以前的记录,发现那个文件超过2MB了就开新文件;而以前的记录肯定是以某个完整的Session结束的(一关闭聊天窗口就保存了一次,也就结束了一个Session)。我还是得想办法以Session为单位获取输出的文本的大小才行


这么说 msn 认识巨大的聊天记录喽? 那不用拆了 ……
3 楼 RednaxelaFX 2009-08-14  
night_stalker 写道
前天我写的扩展也碰到一个莫名其妙的 segfault,一 puts 就 segfault,不 puts 就很正常。最后才发现是 GC 的问题: 在扩展中创建了 ruby 对象,但是忘记 mark 了,puts 正好触发 GC,就挂了 …… 后来添加了一个 mark 就没事了。

我觉得问题应该不难 …… 先输出一个大文件,再写个脚本拆。

就等着你来救火啊~~
我得看看Nokogiri有没有更新过,试试新版本会不会还segfault。会的话看来得动手弄个patch了 = =

先输出大文件再拆,但是“再拆”的时候还是得把文件读进来,解析XML,然后再来……还是回到起点了啊。关键是MSN在拆分聊天记录的时候是不会在Session的中间断开的,这是因为它是在写新记录的时候读以前的记录,发现那个文件超过2MB了就开新文件;而以前的记录肯定是以某个完整的Session结束的(一关闭聊天窗口就保存了一次,也就结束了一个Session)。我还是得想办法以Session为单位获取输出的文本的大小才行
2 楼 night_stalker 2009-08-14  
前天我写的扩展也碰到一个莫名其妙的 segfault,一 puts 就 segfault,不 puts 就很正常。最后才发现是 GC 的问题: 在扩展中创建了 ruby 对象,但是忘记 mark 了,puts 正好触发 GC,就挂了 …… 后来添加了一个 mark 就没事了。

我觉得问题应该不难 …… 先输出一个大文件,再写个脚本拆。
1 楼 lwwin 2009-08-14  
2M 说明你们聊得很凶^^~

相关推荐

Global site tag (gtag.js) - Google Analytics