はてなダイアリー(not はてなブログ)の記事をダウンロードしてローカルで見られるツールを書きました。

t-wadaの日記を古い記事からさかのぼるように読みたいと思った時、はてなダイアリーは記事を古い順に表示する方法がありませんでした。

そのため、指定したはてなダイアリーの記事をすべてダウンロードしてローカルでソートしたHTMLを作って表示することにしました。

neue cc - はてなダイアリー to HTMLを参考にhatenadiary-downloaderというCLIを書きました。

使い方

Node.js 10以上が必要です。

インストール

npxを使えばインストールと同時に実行できます。

npm install hatenadiary-downloader -g
# or
npx hatenadiary-downloader [option]

次のように標準入力にはてなダイアリーのURLを、--outputに出力するHTMLのパスを書きます。 デフォルトでは古い順にソートされます。

Usage
  $ hatenadiary-downloader <URL>

Options:
  --sortOrder "ascending" or "descending" (default: ascending)
  --output -o  output path

Examples
  $ hatenadiary-downloader "http://d.hatena.ne.jp/t-wada/searchdiary?word=*[XP]" -o ./index.html

使い方の例

特定のダイアリーをすべてダウンロードしたい場合はTOPページのURLを指定すればOKです。

hatenadiary-downloader "http://d.hatena.ne.jp/hatenadiary/" -o ./index.html

特定のダイアリーの特定のカテゴリ[XP]のみをダウンロードしたい場合は次のような検索ページのURLを指定すればOKです。

hatenadiary-downloader "http://d.hatena.ne.jp/t-wada/searchdiary?word=*[XP]" -o ./index.html

ダウンロードしたHTML

例えば次のようにt-wadaの日記をすべてダウンロードしてできたt-wada.htmlは4.5MBほどのHTMLになります。

hatenadiary-downloader "http://d.hatena.ne.jp/t-wada/" -o ./t-wada.html

あとはダウンロードしたt-wada.htmlを好きなブラウザで開けばオフラインでも読むことができます。

実装

t-wadaの日記を古い順に読みたくて作っただけなので、その場の直感で1時間ぐらいかけて作りました。

はてなダイアリー ダウンロード - Google 検索でググったところ、neue cc - はてなダイアリー to HTMLが出てきて、そういえばこれなんか過去にやったことがあるのを思い出しました。

Windowsじゃないから似たようなものを作るかと思ってhatenadiary-downloaderというプロジェクト名をmkdevしました。

neue cc - はてなダイアリー to HTMLからProgram.csをダウンロードして、hatenadiary-downloaderのなかにいれて、Program.csをみながらどういうメソッドにするかを考えました。

最初は、まず記事をダウロードするfetchArticleが必要で、記事からコンテンツ領域をパースするparseContentが必要で、クロールするから前の記事を取るfetchPrevioutArticleが必要という感じで次のような空関数をexportするsrc/hatenadiary-downloader.jsというファイルを作りました。 (いわゆるライブラリのエントリポイント)

// それぞれ空の関数をexportした
module.exports.parseContent = parseContent;
module.exports.fetchPrevioutArticle = fetchPrevioutArticle;
module.exports.fetchArticle = fetchArticle;

ここでまずはfetchArticleを実装しようとして記事をみたら、はてなダイアリーは1つの記事に複数のコンテンツがあるサイトであることを思い出しました。 (具体的には1日の中に複数の記事がある)

そして、Program.csを見るとなにやらコンテンツをパースするにはDOMをパースしないといけなさそうだったので、yarn add jsdomしてJSDOMをインストールしました。

JSDOMのREADMEでURLからdocumentどうやって得るんだろ?と検索してたらfromURL()というそのままのメソッドがあったので、これを使うことにしました。(fromURL()を使うとURLのHTMLをパースしてwindowなどよく見るDOM APIを使えるオブジェクトを返してくれる)

ここで、先ほどexportしていた関数は若干ずれているのがわかったので次のような空関数のexportに変更しました。

// URLからdocumentオブジェクトを取得する
const fetchDocument = (URL) => {};
// documentからcontents(それぞれの記事のHTML)を返す
const parseContents = (document) => {};
// documentから前の記事リスト(前の記事)のURLを取得する
const getPrevArticleListURL = (document) => { };
module.exports.fetchDocument = fetchDocument;
module.exports.parseContents = parseContents;
module.exports.getPrevArticleListURL = getPrevArticleListURL;

なんとなく形は見えてきたのであとは実装して、組み合わせばURLからdocument取得 -> コンテンツをパースしてどっかに貯める -> 次のURL -> 繰り返すでクロールできるでしょうと思って中身を実装し始めました。

通信があるCLIのデバッグは面倒なことが多く手動でいちいちテストするのも大変なので、大雑把なテストケースを書いてそこを見ながら開発することにしました。 (最終的なテストはhttps://github.com/azu/hatenadiary-downloader/blob/master/test/hatenadiary-downloader-test.jsにある)

次のようにまずはfetchDocumentでURLからdocumentオブジェクトを取得する部分を書きました。 適当にコンソールログをテストに書いてdocumentをとれるのが確認できました。

// こんな感じでOKだった
const fetchDocument = (URL) => {
    return JSDOM.fromURL(URL, {
        userAgent: "hatenadiary-downloader+",
    }).then(dom => {
        return dom.window.document;
    });
};

同時にfetchDocumentで取得したdocumentPを使って、getPrevArticleListURL`が前のページのURL(クロールはTOPページ = 先頭からおこなうため)が取れるかのテストを書きました。

実際にページへアクセスして<link rel="prev" href="/t-wada/searchdiary?of=3&word=%2A%5BXP%5D" title="前の3日分">rel=prevを見ればいいことがわかったので、この値を直接比較しています。

const assert = require("assert").strict;
const { fetchDocument, parseContents, getPrevArticleListURL } = require("../src/hatenadiary-downloader.js");

describe("hatenadiary-downloader", () => {
    it("should fetch article list and prev url", async () => {
        const document = await fetchDocument("http://d.hatena.ne.jp/t-wada/searchdiary?word=*[XP]");
        const prevURL = getPrevArticleListURL(document);
        assert.equal(prevURL, "http://d.hatena.ne.jp/t-wada/searchdiary?of=3&word=%2A%5BXP%5D");
    });
});

fetchDocumentdocumentオブジェクト(これはブラウザのdocumentとほぼ同じ)が手に入ってるのであとはquerySelectorメソッドなどを使って適当に実装しています。

parseContentsの実装も<div class="day">の中身がそれぞれのコンテンツということがわかった時点で、その要素を含む配列を返せば通るテストを書いて、実装しました。 (あとで邪魔なものを削ったり、リンクを絶対URLに修正する処理を追加した)

    it("should fetch and parse contents", async () => {
        const document = await fetchDocument("http://d.hatena.ne.jp/t-wada/searchdiary?word=*[XP]");
        const contents = parseContents(document);
        contents.forEach(content => {
            assert.ok(content.includes(`div class="day"`));
        });
    });

ここまで実装できたら、あとはURLからドキュメント取得*1 -> パース -> コンテンツを貯める -> 前のページのURLを取得 -> 1へループと書けばクロールできそうと思って、src/cli.jsを書き始めました。

最終的にはCLIから指定したURLを渡したらコンテンツをまとめたものを作るだろうと思っていたので、cli.jsはCLI用のエントリポイントみたいなものです。

cli.jsを実装しながら、URLからドキュメント取得は非同期処理なので逐次的な非同期ループが必要だ!ということに気が付きました。

最初にダウンロードする全部のURLを知ることができないため、Promise.allで一発みたいなことはできずに、非同期 -> パース -> 非同期 -> パースを逐次的にやらないといけなくてとても面倒そうです。

ここで、そういえばAsync iteratorsってこういうときに使うものだったような気がすると思い出しました。 Generatorは普段全然使わなかったため書き方がピンと来ませんでしたが、適当にググってSymbol.asyncIteratorを実装すればどうにかなるっぽいことがわかりました。

実際に動かしながらじゃないとわかりにくそうだったので、Getting Started with Asynchronous Iterators and Generators ― ScotchからSymbol.asyncIteratorを実装したコードをコピペしました。

src/hatenadiary-downloader.jsに、Symbol.asyncIteratorを実装したasync iterableオブジェクトを返す関数が追加できれば、あとはfor await ofでイテレーションすればTOPページからどんどん過去のコンテンツをクロールすることができそうです。

イメージ:

const allContents = [];
// いい感じのAsyncIteatorオブジェクト作って返す
// ReaderといってたのはFetch with Streamを思い浮かべたから
const fetchAsyncIterator = createFetchReader();
// このasync iteratorはnext()するたびに、次のページのdocumentを返す
for await (const document of fetchAsyncIterator)  {
    // documentからcontentsを取り出して貯める
   allContents.push(...parseContents(document));
}
// 最後にHTMLにまとめて出力

非同期でイテレーションする(複数回通信する)ということは正しく動いてるかを確認することが面倒だということです。 とりあえず数回ループが回ってることが確認できれば良いので、次のようなテストを書いて指定回数ループした結果、取得できたdocumentが意図した感じなのをチェックしました。

    it("should async iterator for createFetchReader", async () => {
        const iterator = createFetchReader("http://d.hatena.ne.jp/t-wada/searchdiary?word=*[XP]");
        let count = 0;
        const URLs = [];
        for await (const document of iterator) {
            count++;
            URLs.push(document.location.href);
            if (count > 3) {
                break;
            }
        }
        assert.ok(count, 3);
        assert.deepEqual(URLs, [
            'http://d.hatena.ne.jp/t-wada/searchdiary?word=*[XP]',
            'http://d.hatena.ne.jp/t-wada/searchdiary?of=3&word=%2A%5BXP%5D',
            'http://d.hatena.ne.jp/t-wada/searchdiary?of=6&word=%2A%5BXP%5D',
            'http://d.hatena.ne.jp/t-wada/searchdiary?of=9&word=%2A%5BXP%5D'
        ]);
    });

そしてAsyncIteatorオブジェクトを返すcreateFetchReaderを実装しました。

const createFetchReader = (URL, { intervalTimeMs }) => {
    let prevURL = URL;
    return {
        [Symbol.asyncIterator]: () => {
            return {
                async next() {
                    const currentURL = prevURL;
                    if (!currentURL) {
                        return { done: true }
                    }
                    const document = await fetchDocument(currentURL);
                    prevURL = getPrevArticleListURL(document);
                    return {
                        done: false,
                        value: document
                    }
                }
            }
        }
    }
};

テストで動いてそうなのを確認しましたが、なんかたまにエラーがおきる現象に遭遇しました。 そういえばneue cc - はてなダイアリー to HTMLのコードにタイムアウトするから待つというような処理があったことを思い出して、それぞれのループにはインターバルをもたせたほうが良さそうと思って追加することにしました。

// neue cc - はてなダイアリー to HTMLから抜粋
            retry:
                try
                {
                    var url = HatenaUrl + prev.Attribute("href").Value;
                    Console.WriteLine(url); // こういうの挟むのビミョーではある
                    return XElement.Load(new SgmlReader { Href = url });
                }
                catch (WebException) // タイムアウトするので
                {
                    Console.WriteLine("Timeout at " + DateTime.Now.ToString() + " wait 15 seconds...");
                    Thread.Sleep(TimeSpan.FromSeconds(15)); // とりあえず15秒待つ
                    goto retry; // 何となくGOTO使いたい人
                }

クローラーといえば岡崎市立中央図書館事件メソッドなので、1秒に1回程度のアクセスになるように1秒のwaitを入れることしました。

あとはcli.jsに、このcreateFetchReaderを使ってページのdocumentを取得して、コンテンツを取り出してを繰り返すfor await ofでループするだけです。

最終的に次のような感じでループをまわしています。

// MIT © 2018 azu
"use strict";
const { createFetchReader, parseContents, joinArticleContents, getPrevArticleListURL } = require("./hatenadiary-downloader.js");
const fs = require("fs");
const path = require("path");
const assert = require("assert");
const cli = {
    /**
     * @param {string} URL hatena diary URL
     * @param {string } outputPath output Ptah
     * @param {string } [sortOrder] ascending or descending
     */
    async run(URL, outputPath, sortOrder = "ascending") {
        assert.ok(URL !== undefined, "URL needed");
        assert.ok(outputPath !== undefined, "--output is needed");
        const allContents = [];
        let lastDocument = null;
        const fetchAsyncIterator = createFetchReader(URL, {
            intervalTimeMs: 1000
        });
        for await (const document of fetchAsyncIterator) {
            console.log("Process: " + document.location.href);
            lastDocument = document;
            if (sortOrder === "ascending") {
                allContents.push(...parseContents(document).reverse());
            } else {
                allContents.push(...parseContents(document));
            }

        }
        if (sortOrder === "ascending") {
            allContents.reverse();
        }
        const indexContent = joinArticleContents(lastDocument, allContents);
        fs.writeFileSync(path.resolve(process.cwd(), outputPath), indexContent, "utf-8");
    }
};
module.exports.cli = cli;

あとはこのcli.jsを実際のコマンドとして呼べるように、コマンドライン引数のパーサなどの処理をmeowを使って書いてcli.run()すれば完成です。

ソースコードは以下に公開されています。

感想

  • はてなダイアリーはTwitterみたいだなと読んでて思った
  • id:userで文中にmentionを書いたり(CCするmention)、トラックバックで公式RTみたいな通知だったり、コメントでThreadなやりとりだったり
  • 特に最初のころは1記事が140文字程度の内容が多かったのでTwitterっぽいと思った
  • あと診断アプリみたいなのがたまにでてくるのも
  • 途中でTwitter(2008?9?年)がでてきたぐらいから、書く内容も変化してきて報告形式(登壇、執筆)になってきた気がする(これはやること自体が変わったのもありそう)
  • おそらくTwitterらしい内容はTwitterへ書くように変わっていったのだと推測
  • 書き方の形式が変わっていたのもあり、少し先にピンをおいてそこまで書いて次のピンをおいてそこまで書いていくみたいな印象受けた
  • 最初にアウトラインを書いて中を書いていくような印象
  • 最初の頃は散文で前後に直接的なつながりはなかったり、あったり、酔いつぶれてたり。けど時系列というつながりがあって思考の流れのようなもの見えていた気がした

ということを思いながら"実装"の話を書いていた。

こういう思考の流れを書き出すことはたまにやってる。

こういった思考の流れをブログに書くことって少なくなっていってるんだなという実感を得た気がする。 Twitterはストックではないので、その場その場で書いてもその流れをなにかの形でまとめないと流れとしてみるのは難しい。書いたことで満足してしまうので、なんでこういう結果になったんだろう過程はGitのコミットログにも残ってないから見つけるのが難しい。

最近、何かを調査して修正する時はある種のADR(Architecture Decision Records)のようなテンプレの項目(問題、目的、解決案、試したこと、結果など)を項目を書いて、項目の中身を埋めながら調査していることが多い。

今どきの結果はGitやPRに残ってることが多いので、コミットより粒度が小さい過程は意識して残さないとなくなってしまうのかなと思いました。

今書いてるasciidwango/js-primer: JavaScriptの入門書でも、今まで行ったミーティングログはすべてまとめてある。これはなんでこういうことにしたんだっけというのをあとから振り返るの目的。 (結論を意識して書いているのはtc39-notesをまねた。これがADRというのを知ったのがあとだった。)

書くときもIssueに思ってたことや参考リンクなど貼っていて、最終的な結果(文章)がなぜこうなったのかをできるだけあとから辿れることを意識してる。

あと長い文章書くときはアウトラインと文章を行き来して書いていて、本文に書かなかったことは"未使用"という項目に残している。 (このやり方はアウトライナー実践入門から持ってきたやり方。)

というのがこの記事を書いての感想でした。