Node.jsではてなの自分の記事一覧を取得する

はてなの自分の記事一覧をサクッと取得できるようにしたかったのですが、意外と

  • Node.jsで書かれている
  • 結果をJSONにする

ものが見当たらなかったので結局自分で作ることにしました!

ツールの流れ

はてなから自分の記事一覧を取得する流れは下のようになります。

  1. はてなのAtomPubを使う(認証はこの記事ではBasic認証を使います)
  2. エンドポイントをGETすると最新の記事が10件とれる
  3. 結果はXMLなのでJSONに変換する
  4. 2のレスポンスに次の10件のURLが記載されているので、全部取得終わるまで2, 3を繰り返す

はてなのAtomPub

はてなのAtomPubの仕様については以下Hatena Developer Centerに記載されています。

developer.hatena.ne.jp

認証のところに書かれていますが、取得時に使える認証は

の3種類です。個人的にしか使わないし、簡単な方法でとりたいので今回はBasic認証を使うことにしました!

Basic認証に必要なユーザー名ははてなIDで、パスワードはAPIキーを使用します。

APIキーははてなブログの管理画面から設定>詳細設定に行った場所にあります!

f:id:kenev:20190614063328p:plain
設定>詳細設定

下の方を見るとAtomPubの項目があり、そこにAPIキーがあるのでメモっておきましょう!

f:id:kenev:20190614063417p:plain
APIキー

ブラウザからGETしてみる

それでは、試しに記事一覧をブラウザから取得してみます。

ドキュメントにも記載がありますが、以下のURLで取得できます。

GET https://blog.hatena.ne.jp/{はてなID}/{ブログID}/atom/entry

僕の場合はてなID=kenevブログID=kenfdev.hateblo.jpなので、以下になります。

https://blog.hatena.ne.jp/kenev/kenfdev.hateblo.jp/atom/entry

これをブラウザに入れると、下のようにBasic認証が聞かれるはずです。

f:id:kenev:20190614063856p:plain

ここにユーザー名APIキーを入力すると、下のようなXMLが表示されます。

<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom"
      xmlns:app="http://www.w3.org/2007/app">
  <link rel="first" href="https://blog.hatena.ne.jp/{はてなID}}/{ブログID}/atom/entry" />
  <link rel="next" href="https://blog.hatena.ne.jp/{はてなID}/{ブログID}/atom/entry?page=1377584217" />
  <title>ブログタイトル</title>
  <link rel="alternate" href="http://{ブログID}/"/>
  <updated>2013-08-27T15:17:06+09:00</updated>
  <author>
    <name>{はてなID}</name>
  </author>
  <generator uri="http://blog.hatena.ne.jp/" version="100000000">Hatena::Blog</generator>
  <id>hatenablog://blog/2000000000000</id>
  
  <entry>
    <id>tag:blog.hatena.ne.jp,2013:blog-{はてなID}-20000000000000-3000000000000000</id>
    <link rel="edit" href="https://blog.hatena.ne.jp/{はてなID}/
    ブログID}/atom/edit/2500000000"/>
    <link rel="alternate" type="text/html" href="http://{ブログID}/entry/2013/09/02/112823"/>
    <author><name>{はてなID}</name></author>
    <title>記事タイトル</title>
    <updated>2013-09-02T11:28:23+09:00</updated>
    <published>2013-09-02T11:28:23+09:00</published>
    <app:edited>2013-09-02T11:28:23+09:00</app:edited>
    <summary type="text"> 記事本文 リスト1 リスト2 内容 </summary>
    <content type="text/x-hatena-syntax">
      ** 記事本文
      - リスト1
      - リスト2
      内容
    </content>
    <hatena:formatted-content type="text/html" xmlns:hatena="http://www.hatena.ne.jp/info/xmlns#">
      &lt;div class=&quot;section&quot;&gt;
      &lt;h4&gt;記事本文&lt;/h4&gt;
      &lt;ul&gt;
      &lt;li&gt;リスト1&lt;/li&gt;
      &lt;li&gt;リスト2&lt;/li&gt;
      &lt;/ul&gt;&lt;p&gt;内容&lt;/p&gt;
      &lt;/div&gt;
    </hatena:formatted-content>
    <app:control>
      <app:draft>no</app:draft>
    </app:control>
  </entry>
  <entry>
  ...
  </entry>  
  ...
</feed>

XMLが取得できそうだとわかったところで、Node.jsで実装してみます!

Node.jsで取得する

まずはソースを載せちゃってからポイントを解説します。

const axios = require('axios');
const parseString = require('xml2js').parseString;

const hatenaAPIUser = '<USER_ID>';
const hatenaAPIPassword = '<API_KEY>';
const entryUrl = `https://blog.hatena.ne.jp/<USER_ID>/<BLOG_ID>/atom/entry`;

const creds = `${hatenaAPIUser}:${hatenaAPIPassword}`;
const encoded = Buffer.from(creds).toString('base64');
const authorizationHeader = `Basic ${encoded}`;

/**
 * Promise based timeout
 * @param {number} msec wait milliseconds
 */
function wait(msec) {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve();
    }, msec);
  });
}

/**
 * Promise based XML->JSON Parser
 * @param {object} data XML Data
 */
function parseStringPromise(data) {
  return new Promise((resolve, reject) => {
    parseString(data, (err, result) => {
      if (err) {
        reject(err);
        return;
      }
      resolve(result);
    });
  });
}

/**
 * Get all Hatena entries recursively
 * @param {string} url Entry URL
 */
async function getAllEntries(url) {
  console.log('fetching:', url);
  const entries = await axios.get(url, {
    headers: {
      Authorization: authorizationHeader,
    },
  });

  const {
    feed: { entry, link },
  } = await parseStringPromise(entries.data);

  const next = link.find(l => l.$.rel === 'next');

  const parsedEntries = entry
    .filter(e => {
      const {
        'app:control': [
          {
            'app:draft': [isDraft],
          },
        ],
      } = e;
      return !(isDraft === 'yes');
    })
    .map(e => {
      const categories = e.category ? e.category : [];

      return {
        id: e.id[0],
        title: e.title[0],
        url: e.link[1].$.href,
        published: e.published[0],
        publishedAt: new Date(e.published[0]).getTime(),
        updated: e.updated[0],
        updatedAt: new Date(e.updated[0]).getTime(),
        summary: e.summary[0]._,
        categories: categories.map(c => c.$.term),
      };
    });

  if (next) {
    // recursively fetch if 'next' exists
    const nextHref = next.$.href;
    await wait(500);
    const nextEntries = await getAllEntries(nextHref);
    return [...parsedEntries, ...nextEntries];
  } else {
    return parsedEntries;
  }
}

getAllEntries(entryUrl).then(console.log);

ポイントとしては

  • 外部リクエストにはaxiosを使い、BASIC認証用のHTTPヘッダーを作成している
  • XMLからJSONにはxml2jsというライブラリを使っている
  • 無限ループで負荷かけちゃうの怖いのでリクエスト間に500ミリ秒待つ
  • 最終的なJSONの形は自分で決める

となります。順番に説明します!

外部リクエストにはaxiosを使い、BASIC認証用のHTTPヘッダーを作成している

最近axiosを使う機会が多かったので、なんとなく外部リクエストのライブラリはaxiosにしました。 ライブラリは割とどうでもいいのですが、ポイントはBASIC認証のHTTPヘッダーを作っている部分です。

const creds = `${hatenaAPIUser}:${hatenaAPIPassword}`;
const encoded = Buffer.from(creds).toString('base64');
const authorizationHeader = `Basic ${encoded}`;

...

  const entries = await axios.get(url, {
    headers: {
      Authorization: authorizationHeader,
    },
  });

...

BASIC認証を実現するためにはHTTPヘッダーに Authorization というものを作って、中身はBasic <ユーザー名:パスワードをbase64エンコードした値>となります。

XMLからJSONにはxml2jsというライブラリを使っている

XMLからJSONにするために下のライブラリを使っています。

github.com

axiosで取得したXMLの中身をこのライブラリに通すことで、JSONを取得することができます。

const parseString = require('xml2js').parseString;

/**
 * Promise based XML->JSON Parser
 * @param {object} data XML Data
 */
function parseStringPromise(data) {
  return new Promise((resolve, reject) => {
    parseString(data, (err, result) => {
      if (err) {
        reject(err);
        return;
      }
      resolve(result);
    });
  });
}

...

  const {
    feed: { entry, link },
  } = await parseStringPromise(entries.data);

...

個人的にCallbackがあまり好きじゃないので、Promiseにわざわざ変換したfunction書いてますけど、これは別に必要ないです。Promiseにしてawaitしたかっただけです。

無限ループで負荷かけちゃうの怖いのでリクエスト間に500ミリ秒待つ

コードがバグっていてはてなさんのサーバーに変な攻撃を起こしてしまうのが怖いので、リクエスト間に待ちを発生させるようにしてます。

/**
 * Promise based timeout
 * @param {number} msec wait milliseconds
 */
function wait(msec) {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve();
    }, msec);
  });
}

...

  if (next) {
    // recursively fetch if 'next' exists
    const nextHref = next.$.href;
    await wait(500);
    const nextEntries = await getAllEntries(nextHref);
    return [...parsedEntries, ...nextEntries];
  } else {
    return parsedEntries;
  }

...

先程と同じく、setTimeoutをPromiseにしてawait使いたかったのでわざわざfunction作っています。あとは再帰呼び出しをする直前にawait wait(500)と書いて500ミリ秒待つようにしています。

最終的なJSONの形は自分で決める

XMLからJSONに変換したものは、そのままではものすごく使いづらいので、適宜興味のある項目だけ抜き取って最終的な形に整形します。

...
      const categories = e.category ? e.category : [];

      return {
        id: e.id[0],
        title: e.title[0],
        url: e.link[1].$.href,
        published: e.published[0],
        publishedAt: new Date(e.published[0]).getTime(),
        updated: e.updated[0],
        updatedAt: new Date(e.updated[0]).getTime(),
        summary: e.summary[0]._,
        categories: categories.map(c => c.$.term),
      };
...

こうすると、下のようなJSONになります。

{
  "id": "xxxxxxxxxxxxxxxxxxx",
  "title": "AWS SAA再認定のために5日間頑張ったこと",
  "url": "https://kenfdev.hateblo.jp/entry/saa-recertification",
  "published": "2019-03-08T20:42:58+09:00",
  "publishedAt": 1552045378000,
  "updated": "2019-03-08T20:42:58+09:00",
  "updatedAt": 1552045378000,
  "summary": "昨日AWSソリューションアーキテクト・アソシエイト(以下SAA)に再認定されました。3月中に取得しないといけないというミッションがあったので慌てて先週申し込みました。試験会場の関係で試験日まで5日しか無かったのですが、合格できたので備忘録を残しておきます。 TL;DR(まとめ) …",
  "categories": ["AWS"]
}

まとめ

以上ではてなの記事一覧の取得ができます!最後はfsを使ったりしてファイルにJSONを保存すれば何か他の用途に使うことができるかと思います。

以下リポジトリにコードを格納していますので、興味がある方は使ってみてください!

github.com