yanbe.diff このページをアンテナに追加 RSSフィード

2008-08-15

SafariからエクスポートしたブックマークをOPML形式に変換するスクリプト

00:47 | SafariからエクスポートしたブックマークをOPML形式に変換するスクリプト - yanbe.diff を含むブックマーク はてなブックマーク - SafariからエクスポートしたブックマークをOPML形式に変換するスクリプト - yanbe.diff SafariからエクスポートしたブックマークをOPML形式に変換するスクリプト - yanbe.diff のブックマークコメント

概要

通常,SafariからブックマークをエクスポートするとHTML形式のファイルが出力されます.このHTMLファイルをparseして,RSSリーダーで購読出来るようなフィードのURLを抽出し,ブックマークの階層構造をキープしたままOPML形式に変換するスクリプトを書きました.ソースはこのエントリの末尾にあります.

背景

個人的に,Safariでブックマークに登録しているフィードをiPhoneアプリのNetNewsWireで読みたかったので作りました.なお,一応検索はしたものの同様のことを可能にするスクリプトやソフトウェアは見つかりませんでした.

基本的な動作

Safariからエクスポートしたブックマーク(.html)を与えると,それに".opml"を追加したファイル名でOPML形式で書き出します.出力するOPMLの形式はlivedoor Readerで購読フィードをエクスポートしたときに得られるものを参考にしました.

変換処理のポイント

Safariではフィードをブックマークするとfeed://から始まるURLで保存されるので,それを利用してブックマークのエントリがフィードかどうか判定しています.また,フィードのURLの内容を順次取得して中身を解析することで,元のリソースのURLを抽出します.はまりやすいポイントとしては,StringIOモジュールのUnicode文字列の扱いと,OPMLのURLのパラメータ部分の"&"はエンティティ"&"に置き換える必要があることぐらいだと思います.

感想

今回はより多くの環境で動作するようにするため,feedparserやBeautifulSoupみたいな便利なサードパーティのモジュールを使わずに,Python2.4標準モジュールのHTMLParserやxml.parsers.expatだけで頑張ったのですが,割とめんどいことが分かりました.

TODO

  • RSS形式だけでなくatom形式にも対応させる
  • OPML → Safariブックマークへの変換をサポートする

ソースコード

safari2opml.py

#!/usr/bin/env python
#coding: utf-8

# Convert Exported Safari Bookmark (HTML) into OPML
# Usage: python safari2opml.py safari-bookmark.html

import os
import sys
import urllib2
import datetime
from HTMLParser import HTMLParser
from cStringIO import StringIO
import xml.parsers.expat

class SourceURLExtractor():
  def __init__(self):
    self.source_url = None
    self.type = None
    self._last_element = None
    self._parser = xml.parsers.expat.ParserCreate()

    def start_element(name, attrs):
      self.last_element=name

    def char_data(data): #source url on rss format
      if self.type==None and self.last_element=='link':
        self.source_url=data
        self.type='rss'

    self._parser.StartElementHandler = start_element
    self._parser.CharacterDataHandler = char_data

  def feed(self, xmlstring):
    self._parser.Parse(xmlstring)

class SafariBookmarkToOPML(HTMLParser):
  def __init__(self):
    HTMLParser.__init__(self)
    self._opml=StringIO()
    self._opml.write('''<?xml version="1.0" encoding="utf-8"?>
<opml version="1.0">
<head>
    <title>Safari Bookmarks</title>
    <dateCreated>%s</dateCreated>
    <ownerName>%s</ownerName>
</head>
<body>\n'''
    % ( datetime.datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S UTC'),
        os.environ.get('USER', None)))
    self._indent_level = 0
    self._last_h3 = False
    self._html_url = None
    self._type = None
    self._xml_url = None
    self._wrote_footer = False

  def handle_starttag(self, tag, attrs):
    if tag=='h3':
      self._last_h3=True
    
    if tag=='a':
      for attr, value in attrs:
        if attr=='href' and value.startswith('feed://'):
          feed_url = 'http'+value[4:]
          linkparser = SourceURLExtractor()
          print 'processing:', feed_url
          try:
            feedstring = urllib2.urlopen(feed_url).read()
            linkparser.feed(feedstring)
          #urllib2.HTTPError or xml.parsers.expat.ExpatError
          except Exception, e:
            print 'error:', e
            continue
          if not linkparser.source_url:
            continue
          self._html_url = linkparser.source_url.replace('&', '&amp;')
          self._type = linkparser.type
          self._xml_url = feed_url.replace('&', '&amp;')

  def handle_data(self, data):
    if self._last_h3:
      self._write_line('<outline title="%s">' % data)
      self._last_h3=False
      self._indent_level+=1

    data = data.strip()
    if data and self._html_url and self._type and self._xml_url:
      entry = \
          '<outline title="%s" htmlUrl="%s" type="%s" xmlUrl="%s" />'\
          % (data.decode('utf-8'), self._html_url, self._type, self._xml_url)
      entry = entry.encode('utf-8')
      self._html_url = None
      self._type = None
      self._xml_url = None
      self._write_line(entry)

  def handle_endtag(self, tag):
    if tag=='dl':
      self._indent_level-=1
      self._write_line('</outline>')

  def _write_line(self, line):
    self._opml.write((' '*self._indent_level*2)+line+'\n')

  def get_opml(self):
    if not self._wrote_footer:
      self._write_line('</body></opml>')
      self._wrote_footer=True
    return self._opml.getvalue()

parser = SafariBookmarkToOPML()
bookmark_filename = sys.argv[1]
parser.feed(open(bookmark_filename).read())
open(bookmark_filename+'.opml', 'wb').write(parser.get_opml())
トラックバック - http://subtech.g.hatena.ne.jp/y_yanbe/20080815