こんにちは。SUNABACOスタッフのカンパネルラです。
メモツール(?)のNotionにEvernoteからすべてのノートをデータ移行しようとしたら、Notionのimport機能がエラーを吐いて使い物にならなかったのでシェルスクリプトとPythonを書いてなんとかしました。(雑)
経緯と考察
もともとぼくはブックマークもタスクもメモも思考整理も何もかもEvernoteに直でぶちこんでいたのですが、タイトルも付けず分類もせずタグもつけないという雑な管理方法だったため、一切見返す機会がないまま数千のノートがたまってしまっていました。
流石にそろそろなんとかしないといけないなと思っていたところ、Evernoteより体系的な管理ができそうなNotionというツールを知り、とりあえずNotionを使ってみることにしたという感じです。
Notionは公式でEvernoteからの移行を正式サポートしており、とりあえずまるまるデータ移して処理をしてみて、良さげならそのまま移行、だめそうならEvernoteに戻ってくる、というつもりでいました。
何はともあれEvernoteからの移行機能を試してみたのですが、EvernoteからNotionに数千のノートをimportしようとすると数十ほど読み込まれたところで処理が止まってしまい、100%がimportされません。ノートを分割して10個ずつくらいに分けてやれば確認しながらできるのですが現実的ではありませんし、かといって数百単位でやると抜け漏れを認知できないので知識体系の移行としては流石にやりたくないです。
一応願掛けのつもりでNotionの有料プランを契約したのですが、やはり結果は同じでした。(それはそう)
さて、どうしたものか。
とりあえずエラーの先例を調べてみたのですが、有効的に見える対策はヒットしません。
ただ、公式の「Evernoteからimport」以外に「Evernote→HTMLでexport→NotionからHTMLをimport」という手法でやっている方がいたので、それを試してみることにしました。
すると、「Import Failed」という血も涙もないエラーが出て、1本たりとも読み込まれません。
ここで諦めても良かったのですが、Notionの機能は魅力的ですしimportごときでまるまる諦めるのも癪だったのでなんとかすることにしました。(金も払ったし。)
まず、なぜ”Failed”してしまうのかを考えます。
Evernoteで「test」とだけ書いたノートを用意してHTMLを経由してNotionに読み込むと、無事に読み込まれました。
ということは、一度に読み込む数が原因なのだろうか?
テキストファイルを複数用意して読み込ませると、それも上手くいきました。
ここで、Evernoteから出力されたHTMLを確認すると、ただ単一のHTMLファイルが出力されているノートと、画像が格納された(ほぼ)同名のフォルダと一緒に出力されているノートがあることに気づきます。フォルダはimport対象にできないのでHTMLだけをimportしていたのですが、フォルダと共に出力されているHTMLをいくつかimportさせてみると、予想通り「Import Failed」が表示されました。これが原因のようです。(ちなみにHTMLを覗くと画像のソースが相対パスで記述されていたので、そのへんが原因かなあ、と思っています。)
原因がなんとなく分かったので、目的を達成するための手段を考えます。とりあえず、画像のパスが含まれるHTMLと含まれないHTMLは別物として扱う必要がありそうです。まずは、簡単そうなパスが含まれないHTMLから処理してしまいたい。
画像のパスが含まれるHTMLだけを手動で分類してもよかったのですが、EvernoteのWebクリッパーからブックマークした記事には基本的に画像が含まれてしまうため、千件単位の単純仕分け作業はちょっと人力ではやりたくない量です。
と、いうことで、
①まずは画像のパスが含まれないHTMLファイルを分離する(このファイルは問題なくNotionに取り込める)
②残ったHTMLファイルは基本的にブックマークなので、HTMLファイル内からURLだけでも救出してNotionに取り込む
をとりあえずの目標にしてやっていきます。
シェルスクリプトの話を少しだけ
今回は手法としてファイルの移動を伴うので特に何も考えずにbashのスクリプトを選択しました。(多分osモジュール使って全部Pythonでやることもできると思います。)
bashというのはシェルの一種で、シェルスクリプトはそのシェルが解釈するプログラミング言語、ということになります。
シェルとはOSの基幹部分とユーザーの架け橋になるインターフェイスのことを言うのですが、ここでは詳しくは触れないのでもし興味があれば調べてみてください。
Macでは「ターミナル」というソフトウェアを起動すればシェルスクリプトをそのまま実行することができます。
echo 'hello,world!!'
とターミナルで記述してエンターキーを押せば、スクリプトが動作することが確認できるはずです。
ただ、今回は.shというファイルを作成し、そのファイルが存在するディレクトリで「source test.sh」のようなコマンドで実行するという形式をとっていきます。
また、macOS Catalinaのバージョン以降、ターミナルのデフォルトのシェルがbashからzshへ変更になりました。
基本的には、ターミナルの入力欄が「$」で終わっていればbash、「%」で終わっていればzshであると判断してよいかと思います。(正確な判断基準にはなり得ませんが、そこをデフォルトから変更できてしまうような方はbashかzshかの判断はできるだろう、ということで。。)
今回は、筆者はmacOS Catalinaの環境を使っていますがインターネット上の情報量の都合からbashを選択しました。
ターミナル>環境設定>一般>開くシェル>コマンド(完全パス)
に「/bin/bash」と入力してターミナルを起動すれば、だいたいの環境ではターミナルのシェルがbashに変更されると思います。
画像パスの含まれないHTMLを別フォルダに分離する
まずは画像パスが含まれないHTMLを分離する作業です。これは基準としては明快で、画像パスが含まれるHTMLは[同名].sourceという名前のフォルダが同時に生成されているため、それを判別すればいけそうです。
目的のフォルダにはファイルとフォルダが混在していたので、まずはファイルだけを抽出しなければなりません。
その手法を調べるとbashでファイルとフォルダをそれぞれ別の配列に格納して出力していく記事を見つけたので、基本的にはそれを改変していきます。
考え方としては
①配下に存在するパスをすべて取得し
②それをファイルかディレクトリかを判別してファイルであれば専用の配列に格納していき
③配列からひとつずつ取り出して
③-1 ファイル名に.sourceをつけた文字列と同名のディレクトリがあれば何もせず
③-2 同名のディレクトリがなければ専用のフォルダに移動する
という感じでいきます。
コードは以下の通りです。
sortfiles.sh
#!/bin/bash
# すべてのパス名を取得
allpaths="/Users/campa/Desktop/note/*"
# ファイル名を入れていく配列を初期化
files=()
# ファイルだけ配列に入れていく
for filepath in $allpaths; do
if [ -f "$filepath" ] ; then
files+=("$filepath")
fi
done
# ファイルをひとつずつ取りだす
for filepath in ${files[@]}; do
# 画像フォルダはファイル名の末尾に".resources"がつく
dirpath="${filepath}.resources"
# filepathに".resources"をつけたものがdiraryにあるか確認
if [ -d $dirpath ] ; then
# あったらなにもしない
echo "${filepath}はフォルダも存在します"
else
# なかったらファイルを専用のフォルダに移動
mv ${filepath} "/Users/campa/Desktop/noimg"
echo "${filepath}を移動しました"
fi
done
これで、画像パスを含まないHTMLだけを隔離することができました。問題なくNotionにimportできたので、とりあえず第一目標は達成です。
HTMLからURLだけ取り出す
次に、フォルダに残った画像パス入りのHTMLからURLを救出していきます。HTMLを眺めていると基本的にEvernoteWebクリッパーでブックマークしたものは同じ形でsourceとなるURLが入っていてくれたため、そこだけ正規表現でぶっこ抜くことをまず考えます。
手順としては、
①先ほどと同じ要領でファイルのパスだけを配列に格納
②ひとつずつ取り出して
②-1 正規表現でURL部分だけ取り出し
②-2 それを別ファイルに書き出していく
というかんじでいきます。ファイルのパスだけを格納するコードをbashで書いていたので、そのままbashでいきます。
書き出しはとりあえず様子見で1ファイルに追記されていくかんじにしました(デスクトップに”urllist.txt”というテキストファイルを作成した)。
URLだけ取り出す部分のコードは以下の通りです。
geturl.sh
#!/bin/bash
# 区切り文字を改行コードのみに変更
PRE_IFS=$IFS
IFS=$'\n'
# フォルダ内すべてのパスを取得
allpaths="/Users/campa/Desktop/note/*"
# ファイル名を入れていく配列を初期化
files=()
# ファイルのパスだけ配列に入れる
for filepath in $allpaths; do
if [ -f "$filepath" ] ; then
files+=("$filepath")
fi
done
# URLだけ取り出す
for filepath in ${files[@]}; do
cat ${filepath} | grep -o '<meta name="source-url" content=.*><meta name="updated"' | sed 's#<meta name="source-url" content=\(.*\)><meta name="updated"#\1#' >> /Users/campa/Desktop/urllist.txt
done
IFS=$PRE_IFS
ポイントとしては、Evernoteに書き溜めていたノートをそのまま書き出した都合上ファイル名にスペースが入ってしまっており、そのままだとえらいことになってしまったので環境変数IFS(Internal Filed Separator)=区切り文字 を改行コードのみに変更しています。
これを実行すると、とりあえず改行区切りのURL一覧がtxt形式で出力されます。
一覧が得られたのでいちおうNotionに入れてみたのですが、URL打っただけでは特にプレビューなどはされないようです。
プレビューされたらそれでいいかな〜という甘い期待を持っていたのですが、されなかったので流石にURLが一覧であるだけではちょっと情報ストックとしては成り立ちません。
ということで、せめてタイトルくらいは欲しいのでタイトルをとってくることを考えます。
URLから記事のタイトルを取得する
Notion側にURLを入れるとリッチな形式でタイトルとOGP画像を表示させてくれる仕組み(Web Bookmark)があったので、まずはそれがどういう仕組みで成り立っているのかを確認します。
Notion側でWeb Bookmark機能にURLを入力してノートを作成してNotionからHTMLで書き出してみたのですが、普通にCSSやらがバリバリに書いてあって再現がめんどそうだったので、HTMLを作成して読み込ませるというやり方はとりあえず却下しました。
次にWeb BookmarkをMarkdownで書き出してみると、見出しとリンクがあるだけのシンプルな構成でした。これでいこう。
Evernoteで書き出したHTMLからタイトルも抜いてきてもよかったのですが、せっかくURLの一覧があるんだからスクレイピングでページのHTML取ってきてtitleタグから抜き出せばええやん、ということでそっちの方向でやりました。
手順的には
①一覧のtxtファイルからいらない文字列を消してURLだけにして
②改行タグでsplitして必要十分なURL文字列の配列にして
③ひとつずつ取り出して
③-1 URLにアクセス
③-2 HTMLを取得してtitleだけ切り出す
③-3 適当にMarkdownにして別ファイルで保存
ってかんじでいきます。
コードはこんなかんじ。
titlelistという名前のMarkdownファイルたちを入れる用のフォルダをつくっています。
fetchtitle.py
import requests
from bs4 import BeautifulSoup
from requests.exceptions import Timeout
# URL一覧のファイルのパス
path = "/Users/campa/Desktop/urllist.txt"
# ソースファイルを文字列として取得
with open(path) as text:
# いらない文字列をとる
refined = text.read().replace('\"', '')
# 改行コードで切って配列にする
urls = refined.split("\n")
# urlにひとつずつアクセスしてタイトルを持ってくる
for count, url in enumerate(urls):
try:
html = requests.get(url, verify=False, timeout=(3.0, 7.5)).text
soup = BeautifulSoup(html, "html.parser")
title = soup.title.string
except Timeout:
title = "タイムアウトが発生しました。"
except:
title = "エラーにより取得できませんでした。"
# ファイル名が「6_記事名.md」って感じになるようにする
savepath = "/Users/campa/Desktop/titlelist/" + str(count) + "_" + title +".md"
# 記事部分は見出し+タイトル名のリンク
article = "# " + title + " \n \n [" + title + "](" + url + ")"
# 保存処理
try:
with open(savepath, mode="w") as f:
f.write(article)
except FileNotFoundError:
savepath = "/Users/campa/Desktop/titlelist/" + str(count) +"_ファイル名エラー.md"
article = "# " + title + " \n \n [" + title + "](" + url + ")"
with open(savepath, mode="w") as f:
f.write(article)
ブックマークなので消されてしまったりしている記事も多く、エラーが多く発生しました。見られないわけなので無視しちゃってもよかったんですが、精神的に何かが失われたようで落ち着かないので例外処理書いて分かるように保存されるようにしました。
スクレイピングのところの例外処理は一般的にはもうちょっと細かく書いたほうがいいところですが、どうせエラーになったやつは手動で見に行って確認するしなー、と思って、例外処理を細かく書く手間と例外処理によって楽になる労力を天秤にかけてこんな感じにしています。
バンバンエラー出たので、エラーを見ながら随時タイムアウト設定したりSSLサーバー証明書の検証を無視したりとかしてます。細かいところはググってみていただければと思います。
ちなみにこれもbashでやってもよかったんですが、個人的にPythonが慣れてたのでPythonでやりました。多分全部bashでもいけると思います。が、感覚的には例外処理とかがPythonのほうが綺麗に書ける気がするので(たぶん)結果的にPython選んだのは正解だったかな〜と思っています。
これで出力されたものをNotionに取り込むと無事にタイトルの分かる形でリンクが別ノート扱いで得られたので、一旦完成ということにしました。
おわりに
今回は手動でもできる(できるとは言っていない)大量のデータの取り回しを、コード書いてやれるだけ楽をしようということをやりました。
今回ほど多くなければ別に手動でやってもいいのですが、最悪できなくてもなんとかなるそういう時こそプログラマとしては練習だと思って積極的にコード書いて楽していきたいですね。
このNoteはHanahanaworksが運営するコワーキングスペース&プログラミングスクール「SUNABACO」のメンバーが執筆しています。
SUNABACOでは通常のプログラミングスクールの他に、休日には様々なイベントを定期開催しています。イベント情報は公式サイトやTwitterで入手することができます。
一緒に学び続けましょう!