ショートカット・ディレクトリとプレインテキストの変換

ショートカット・ディレクトリとプレインテキストの変換
====================================================

[機械学習関連情報の収集と分類(構想)](http://qiita.com/suchowan/items/459062590f7134dfc138)の❺の詳細です。

あまり応用の効かない情報ですので Qiita 的には面白くないかもしれませんが、メモとして残しておきます。

(1) 概要
--------

ネットサーフィンして発見した記事を手動で分類するには様々なやり方が考えられます。

もっとも素直なのはブラウザやクラウドのブックマークに登録することでしょう。しかし、実際にやってみるとプルダウンメニューの操作が必要だったりして意外に時間がかかる。

色々試行錯誤した結果、エクスプローラーのツリービューを用いてドラッグ＆ドロップでショートカットを配置するのが、(操作への慣れもあって)単位時間当たり一番効率が良いという結論になりました。

しかしファイルシステム上に分類結果を置いたまま利用するのはかなり不便です。

・OSに依存するファイル名

ブラウザから取得してドラッグ＆ドロップでつくるショートカットのファイル名はもとのウェブページの< Title/>部分をファイル名にします。OSによってファイル名に使える文字の種類が異なり、またファイル名に許容される長さも異なります。

・大量の細切れファイル

何千個もの細切れショートカットファイルができるので、その読み取りアクセスが無駄に頻繁になります。

そこで、[機械学習関連情報の収集と分類(構想)](http://qiita.com/suchowan/items/459062590f7134dfc138)の❺の処理によって、ブックマークをプレインテキストにまとめてしまい、そのプレインテキストを以降の処理のインプットにしようというわけです。

(2) ショートカットファイル
--------------------------

ショートカット・ディレクトリをプレインテキスト化にする際には、基本的には各ショートカットはそのまま内容をデッドコピーすればいいのですが、ひとつだけ注意しなければならないのがタイムスタンプです。

Modified という項目があってショートカットの更新日時を表現しているようです。

[InternetShortcutについて](http://www.atmark.gr.jp/~s2000/r/rtl/InternetShortcut.html)などを参考に、Modified 文字列を日時に変換するコードを書いてみたのが下記の< serial.rb >です。

```rb:serial.rb
require 'when_exe'
require 'when_exe/core/extension'

include When

Epoch  = when?('1600-12-30T08:59:59.844+09:00:00')

def serial2date(serial)
  seed = 0
  8.times do |i|
    seed = seed * 256 + serial[(-4-2*i)..(-3-2*i)].to_i(16)
  end
  Epoch + When::PT1S * (seed / 10_000_000.0)
end
```

Modified は特定の元期からの経過時間を100ナノ秒単位で計算した結果を16進で表現したもののようです。[when_exe](https://rubygems.org/gems/when_exe) gem を避けたければ適当に修正してください。


(3) ショートカット・ディレクトリ→プレインテキスト
--------------------------------------------------

ショートカット・ディレクトリをプレインテキスト化するスクリプト< dir2list.rb >を下記に示します。

ショートカットファイル名の先頭に'★'をつけて重要であることを示すようにしているのはローカルルールです。

ショートカットファイルの読み込みでエラーが起こるのはOSに依存するようなファイル名が使われているケースですので、そのような場合あらかじめ手動でファイル名を変更しておく必要があります。

```rb:dir2list.rb
=begin
  Usage:

    ruby dir2list.rb <root> (<filter>) > <list>

    root   : インターネットショートカットを階層的に配置したファイル群のルートディレクトリ名
    filter : リスト化するディレクトリのリスト(省略すると全ディレクトリをリスト化)
    list   : インターネットショートカットのリスト(プレインテキスト)
=end

require './serial'

Encoding.default_external = 'UTF-8'
Encoding.default_internal = 'UTF-8'

root, filter = ARGV
root += '/'
if filter
  ex = []
  IO.foreach(filter) do |line|
    ex << line.chomp.gsub("/", "\\/")
  end
  filter = /^(#{ex.join('|')})/
end

Dir.glob(root + '**/*.*') do |path|
  next unless path =~ /^(.+)\.(url|website)$/i
  title     = $1.gsub(/%7f/i, '%7E').sub(root, '')
  next if filter && filter !~ title
  timestamp =
   begin
     File.stat(path).mtime.to_tm_pos
   rescue => e
     STDERR.puts e
     next
   end
  contents  = []
  IO.foreach(path) do |line|
    begin
      case line
      when /^URL/, /^IconFile/
        contents << line
      when /^Modified=([0-9A-F]+)/i
        serial = $1
        contents << "Modified=#{serial}"
        timestamp = serial2date(serial)
      end
    rescue ArgumentError
    end
  end
  raise ArgementError, "#{path} is empty" if contents.empty?
  important = title.sub!(/\/★/, '/')
  puts '%s%s %s' % [important ? '+' : '=', timestamp.to_s, title]
  puts contents
  puts
end
```

プレインテキストの中身は下記のようなものの羅列になります。

```
=2016-10-21T09:56:55.00+09:00 Computer/トピック/機械学習/応用/自然言語処理/Ruby からの Watson Natural Language Classifier 利用例 - Qiita
URL=http://qiita.com/suchowan/items/4bb4db40c4ff434ee847
```

最新のプレインテキストについては[機械学習関連ブックマークなど](http://qiita.com/suchowan/items/7fd43a5950170782b467)をご覧ください。


(4) プレインテキスト→ショートカット・ディレクトリ
--------------------------------------------------

プレインテキストをショートカット・ディレクトリ化するスクリプト< list2dir.rb >を下記に示します。

```rb:list2dir.rb
=begin
  Usage:

    ruby list2dir.rb <list> <root>

    list : インターネットショートカットのリスト(プレインテキスト)
    root : インターネットショートカットを階層的に配置するファイル群のルートディレクトリ名
=end

require 'fileutils'
require './serial'

Encoding.default_external = 'UTF-8'
Encoding.default_internal = 'UTF-8'

def url(root, title, importance, timestamp, contents)
  title.sub!(/([^\/]+)$/, '★\1') if importance == '+'
  timestamp = timestamp.to_time
  path = root + title
  dir  = path.split('/')[0..-2].join('/')
  FileUtils.mkdir_p(dir) unless FileTest.exist?(dir)
  file = path +'.url'
  open(file, 'w') do |url|
    url.puts('[InternetShortcut]')
    contents.each do |content|
      url.puts content
    end
  end
  File::utime(timestamp, timestamp, file)
end

list, root = ARGV
root += '/'
title, importance, timestamp, contents = nil

IO.foreach(list) do |line|
  case line
  when /^([-+=])(.+?)\s+(.+)\s*/
    importance, date, title = $~[1..3]
    timestamp = When.when?(date)
    contents = []
  when /^Modified=(.+)$/
    contents << line.chomp
    timestamp = serial2date($1)
  when /^\s*$/
    url(root, title, importance, timestamp, contents) unless contents.empty?
    contents.clear
  else
    contents << line.chomp
  end
end
```

(5) プレインテキスト→クロールド・コンテンツ・ディレクトリ
----------------------------------------------------------

ショートカット・ディレクトリを参照して、その実体をクロールして取得するスクリプト< crawl.rb >を下記に示します。

簡単のため URL の拡張子部分が pdf かどうかだけで取得方法を切り替えていますが、実際にはもっと細かく判断する必要があるでしょうね。

```rb:crawl.rb
=begin
  Usage:

    ruby crawl.rb <root> (<filter>)

    root   : インターネットショートカットを階層的に配置したファイル群のルートディレクトリ名(ショートカット・ディレクトリ)
    filter : クロールするディレクトリのリスト(省略すると“computer.filter.txt”を使用)
=end

require 'open-uri'
require 'openssl'
require 'fileutils'
require './serial'

Encoding.default_external = 'UTF-8'
Encoding.default_internal = 'UTF-8'

def crawl(path, timestamp, url)
  timestamp = timestamp.to_time
  path.sub!('/', '.crawled/')
  path.sub!(/\.(url|website)$/i, url =~ /\.pdf(#.+)?$/i ? '.pdf' : '.html')
  dir = path.split('/')[0..-2].join('/')
  FileUtils.mkdir_p(dir) unless FileTest.exist?(dir)
  unless File.exist?(path)
    begin
      puts path
      args  = [url.sub(/#[^#]*$/,''), path =~ /\.pdf$/ ? 'rb' :  'r:utf-8']
      args << {:ssl_verify_mode=>OpenSSL::SSL::VERIFY_NONE} if url =~ /\Ahttps:/
      open(*args) do |source|
        open(path, path =~ /\.pdf$/ ? 'wb' :  'w:utf-8') do |crawled|
          crawled.write(source.read)
        end
      end
      File::utime(timestamp, timestamp, path)
    rescue
      File.delete(path) if File.exist?(path)
    end
  end
end

root, filter = ARGV
root += '/'
ex = []
IO.foreach(filter || 'computer.filter.txt') do |line|
  ex << line.chomp.gsub("/", "\\/")
end
filter = /^(#{ex.join('|')})/

Dir.glob(root + '**/*.*') do |path|
  next unless path =~ /^(.+)\.(url|website)$/i
  title     = $1.gsub(/%7f/i, '%7E').sub(root, '')
  next if filter && filter !~ title
  timestamp =
   begin
     File.stat(path).mtime.to_tm_pos
   rescue => e
     STDERR.puts e
     next
   end
  contents  = []
  url = nil
  IO.foreach(path) do |line|
    begin
      case line
      when /^URL=(.+)/, /^IconFile/
        contents << line
        url = $1 if $1
      when /^Modified=([0-9A-F]+)/i
        serial = $1
        contents << "Modified=#{serial}"
        timestamp = serial2date(serial)
      end
    rescue ArgumentError
    end
  end
  raise ArgementError, "#{path} is empty" if contents.empty?
  crawl(path, timestamp, url)
end
```