このブログは更新を終了しました。移転先はこちらです。

2024-01-23

サイトの作り方④取得したデータを加工する

 Noratetsu Houseの作り方の続きです。

 今回はDynalist APIで取得したデータの加工について。

経緯と断り書き

 ここまでの記事はこちら。

 記事タイトルは「サイトの作り方」となっていますが、これはあくまで私の個人サイトの「Noratetsu House」の作り方です。長いので省略して書いています。

 この一連の記事は「のらてつのサイトはどうやってできているのか」を書いておくものであり、勉強の取っ掛かりになるためにやっているだけなので、正確で十分な知識を提供するものではありません。


 

本題に入る前に

 Dynalist APIで自分のデータを取得するにあたっては、前回書いたように自分のアカウントのシークレットトークンとファイルIDを送信する必要があります。これが他の人にわかってしまうと非常にまずいので、htmlで読み込むscript内(script要素内やjsファイル内)でこれらを記述してはいけません。サイトの訪問者に丸見えになってしまうからです。

 なので、データの取得は公開するサイトとは切り離されたサーバーサイド(≒自分のパソコン内)で予めやっておかなくてはなりません。そのためにはNode.jsまたはDenoを使う必要があるので(JavaScriptを使う場合)、まだインストールしていないという方はまずそちらをお調べいただきたいと思います。なお、歴史が長いNode.jsの方が情報は豊富です。使い方が簡単なのはDenoです(DenoはNode.jsの反省を元に作られたものです)

 

おさらい

 APIへの通信方法の復習です。なにがなんだかという方は前回記事サイトの作り方③Dynalist APIを理解するをご参照ください。

fetch('https://dynalist.io/api/v1/doc/read', {
  method: 'POST', // methodにPOSTを指定する
  body: JSON.stringify({ // bodyにjson形式で情報を記述する
    token: '<secret token>',
    'file_id': '<file id>',
  }),
})
    .then(response => response.json())
    .then(data => {
        console.log(data);
        // dataを使って任意の処理
    })

 返ってくるデータのオブジェクト(上記のコード内ではdataに入っています)は以下のような形。nodesプロパティの中に全てのノードの情報が入っています。

{
  "_code": "OK",
  "_msg": "",
  "file_id": "<document id>",
  "title": "Todo list",
  "version": 15,
  "nodes": [
    {
      "id": "<node #1 id>",
      "content": "Buy milk",
      "note": "2L whole milk",
      "created": 1552959309804, // timestamp in milliseconds of the creation time
      "modified": 1552959335182, // timestamp in milliseconds of the last modified time
      "children": []
    },...
  ]
}

 そして取得できるノードデータの型は次のようになっています。key名に「?」が付いているのは、ノードごとにデータがあったりなかったりするプロパティです。

{
    id: string, // ノードリンク https://dynalist.io/d/<file id>#z=<id> の<id>の部分
    content: string, // 本文
    note: string, // ノート欄
    checked?: boolean, // チェックされているかどうか
    checkbox?: boolean, // チェックボックスが設定されているかどうか
    color?: number, // カラー設定は何番目か(0~6)
    heading?: number, // 見出し設定は何番目か(0~3)
    created: number, // 作成日時(ミリ秒)
    modified: number, // 更新日時(ミリ秒)
    collapsed?: boolean, // 子項目を折り畳んでいるかどうか
    children: string[], // 子要素のidの配列
}

 

どんなデータが必要なのか

 サイトを構築するのが目的なので、そうするのに都合が良いデータをまず用意することになります。Dynalist APIがくれるオブジェクトそのままではとても扱えないため、記事単位のデータを作り直すわけです。

 はじめに考えるべきは、「どんな形のデータが欲しいのか」ということです。記事のデータとしてあってほしい要素は何なのかを自分で考える必要があります。

 私の場合はNoratetsu Houseに記事等を表示するために、諸々の加工を経て以下のような型のデータを作リ出しています。

{
    id: string,
    title: string,
    body: string, // 本文(HTML変換済み)
    type: 'article' | 'dict' | 'piece' | 'meta', // 記事か用語か茶の間かその他特別なデータか
    tag?: string[],
    date?: number, // unix
    url?: string, // 外部の投稿先URL(articleのみ)
    definition?: string, // マウスオーバーで表示するテキスト(dictのみ)
}

 id、title、body以外の要素は、予めルールを決めてDynalistにデータを作っておく必要があります。

 タグや日付、URLなど文字列で書かなくてはならない情報は、記事タイトルを書いたノードのノート欄を使うと楽です。子項目に列挙する形だとちょっと面倒になります。

 typeのようにいくつかの選択肢から選ぶものは、種類が十分に少なければheadingやcolorプロパティを活用することができます。他には、タグなどと同様にノート欄に書く方法、タイトルの頭に記号や絵文字を付けておく方法、特定のノードの子孫項目に入れる方法があり得るでしょう。

 ひとまず話をシンプルにするため、記事ノード(=記事タイトルを書いてあるノード)は以下のような形式にしているものとします。また、記事ノード以外のノードにはこの色は使っていないことを前提とします。

画像

 

実践

 さて、実際にスクリプトを作っていきましょう。まず赤色のノードを取得する関数を作ってみます。

function getRedNodes(nodes){
    return nodes.filter(obj => obj.color && obj.color === 1); // colorプロパティが存在しないノードがあるため「obj.color &&」が必要
}

 これで赤色のノードが取得できます。しかし赤色しか取得できないので、色番号を引数に渡せるようにして一般化したほうが使い勝手が良さそうです。

function getNodesByColor(nodes, num){
    return nodes.filter(obj => obj.color && obj.color === num);
}

 簡単ですね。これはあらゆる位置にあるノードを取得するので、ルート直下でも、10層下りたところでも問題ありません。

 では、各ノードのノート欄からtag、date、urlを取得する関数を作ります。正規表現とmatchが必要です。

function getMetadata(note){
    const regexp = (key) => new RegExp(<code>${key}: *([^\n$]*)</code>);
    const tag = (() => {
        const match = note.match(regexp('tag'));
        if (!match) return [];
        const split = match[1].split(/\s/);
        return split.map(str => str.replace('#', '').replace(/_/g, ' ')).filter(v => v);
    })();
    const date = (() => {
        const match = note.match(regexp('date'));
        const dateObj = match ? new Date(match[1]) : new Date();
        return Math.floor(dateObj.getTime() / 1000); // UNIX時間にする
    })();
    const url = (() => {
        const match = note.match(regexp('url'));
        return match ? match[1].trim() : '';
    })();

    return { tag, date, url };
    /* これは以下と同じです。keyと変数名が同じ場合は簡潔に書くことができます。
    return {
        tag: tag,
        date: date,
        url: url,
    }
    */
}

 具体的にこの関数をどう使うかはこの後で書きます。とりあえずこれで、ノート欄の文字列を引数に渡してtag、date、urlを得られるようになりました。

 

 今度は本文です。記事ノードの子孫ノードが本文である場合の処理を考えていきましょう。(私の実際のアウトラインは記事ノードの直下にもう一段階区分を設けていますが、今回は記事ノードの子孫項目全体が本文ということにします。)

 子孫項目を本文とする時、階層をどう解釈するべきか。色々やりようがあります。私は階層の深さは無視して上から順番に本文にする形を取っています。つまり、以下のアウトラインがあったとすると、

画像

本文はこうなります。

画像

 なお折り畳んだ部分は無視します。

 他のやり方としては、本文は直下のものだけで、更にその子孫項目はメモ扱いにするということもあり得るでしょう。いくらでも工夫の余地があります。

 まず直下のノードだけを本文として取得する関数を作ってみましょう。(上の例のような子孫全部の取得はちょっとトリッキーなので後述します。)

// nodesは全ノードのデータ、parentは記事ノード
function getBody(nodes, parent){
    const children = parent.children.map(id => nodes.find(obj => obj.id === id));
    const body = children.map(obj => obj.content).join('\n');
    return body;
}

 これをこれまでの関数と合わせて使うとこうなります。

// 各関数はファイル内に書いておく
fetch('https://dynalist.io/api/v1/doc/read', {
  method: 'POST', // methodにPOSTを指定する
  body: JSON.stringify({ // bodyにjson形式で情報を記述する
    token: '<secret token>',
    'file_id': '<file id>',
  }),
})
    .then(response => response.json())
    .then(data => {
        const articleNodes = getNodesByColor(data.nodes, 1);
        const result = articleNodes.map(obj => {
            const id = obj.id;
            const title = obj.content;
            const { tag, date, url } = getMetadata(obj.note); // 分割代入
            const body = getBody(data.nodes, obj);
            return { id, title, tag, date, url, body };
        })
        console.log(result); // 加工して生成したデータ配列
        // 更にresultを使って任意の処理
    })

 コード中にある分割代入についてはこちらをご参照ください。

 これでDynalistのデータを記事データに変換することができました。(保存方法は後述します。)

 

 さて本文取得の話に戻りますが、今度は子孫項目全体を本文として取得する方法を考えましょう。そのためには、childrenプロパティを用いたループ処理(再帰処理)が必要になります。各ノードについて末端に至るまでchildrenを辿り続けるということです。

// nodesは全ノードのデータ、parentは記事ノード
function getBody(nodes, parent){
    const result = []; // 本文を順々に入れていく配列
    roop(parent);

    function roop(node) {
        if(node.collapsed) return; // 畳んでいる場合は子項目を取得しない
        if(!node.children) return; // childrenプロパティが存在しない場合スキップ

        node.children.forEach(id => {
            const find = nodes.find(obj => obj.id === id);
            if(find) {
                result.push(find.content);
                roop(find);
            }
        })
    }

    return result.join('\n'); // 改行文字で繋げて文字列にする
}

 この形を基本として、頭に「//」が付いていればコメントアウトとしてスキップするとか、「・」が付いていれば箇条書きにするとか、記法を決めてdetails要素を作れるようにするとか、色々なオプションを付け加えています(今回は割愛します)

 

データをファイルに保存する

 データを作ったら、それをローカルに保存する必要があります。どういうファイル形式が望ましいかそれぞれ違うでしょうが、とりあえずjsonファイルとして保存することにします。

 長くなるので折り畳んでおきます。jsファイルのファイル名は「getData.js」とでもしておきましょう。

Node.jsを使う場合

※Node.jsのインストールは済んでいるものとします。

// getData.js
const fs = require('fs'); // ファイルシステムを使うためのおまじない

function getNodesByColor(nodes, num){
    return nodes.filter(obj => obj.color && obj.color === num);
}

function getMetadata(note){
    const regexp = (key) => new RegExp(<code>${key}: *([^\n$]*)</code>);
    const tag = (() => {
        const match = note.match(regexp('tag'));
        if (!match) return [];
        const split = match[1].split(/\s/);
        return split.map(str => str.replace('#', '').replace(/_/g, ' ')).filter(v => v);
    })();
    const date = (() => {
        const match = note.match(regexp('date'));
        const dateObj = match ? new Date(match[1]) : new Date();
        return Math.floor(dateObj.getTime() / 1000); // UNIX時間にする
    })();
    const url = (() => {
        const match = note.match(regexp('url'));
        return match ? match[1].trim() : '';
    })();
    return { tag, date, url };
}

function getBody(nodes, parent){
    const result = []; // 本文を順々に入れていく配列
    roop(parent);

    function roop(node) {
        if(node.collapsed) return; // 畳んでいる場合は子項目を取得しない
        if(!node.children) return; // childrenプロパティが存在しない場合スキップ

        node.children.forEach(id => {
            const find = nodes.find(obj => obj.id === id);
            if(find) {
                result.push(find.content);
                roop(find);
            }
        })
    }

    return result.join('\n'); // 改行文字で繋げて文字列にする
}

fetch('https://dynalist.io/api/v1/doc/read', {
    method: 'POST', // methodにPOSTを指定する
    body: JSON.stringify({ // bodyにjson形式で情報を記述する
        token: '<secret token>', // Dynalistのサイトから取得したtokenを記述してください
        'file_id': '<file id>', // 対象となるドキュメントのidを記述してください
  }),
})
    .then(response => response.json())
    .then(data => {
        const articleNodes = getNodesByColor(data.nodes, 1);
        const result = articleNodes.map(obj => {
            const id = obj.id;
            const title = obj.content;
            const { tag, date, url } = getMetadata(obj.note);
            const body = getBody(data.nodes, obj);
            return { id, title, tag, date, url, body };
        })
        
        const json = JSON.stringify(result, null, '\t'); // インデントを反映した形で文字列にする
        fs.writeFileSync('data.json', json, 'utf8'); // ファイルに書き込む
    })

 これをCLI(コマンドプロンプトなど)で実行します*1

node getData.js

 すると一瞬でjsonファイルが生成されると思います。

 


Denoを使う場合

※Denoのインストールは済んでいるものとします。

// getData.js
// 一番最後以外はNode.jsの場合と同じです

function getNodesByColor(nodes, num){
    return nodes.filter(obj => obj.color && obj.color === num);
}

function getMetadata(note){
    const regexp = (key) => new RegExp(<code>${key}: *([^\n$]*)</code>);
    const tag = (() => {
        const match = note.match(regexp('tag'));
        if (!match) return [];
        const split = match[1].split(/\s/);
        return split.map(str => str.replace('#', '').replace(/_/g, ' ')).filter(v => v);
    })();
    const date = (() => {
        const match = note.match(regexp('date'));
        const dateObj = match ? new Date(match[1]) : new Date();
        return Math.floor(dateObj.getTime() / 1000); // UNIX時間にする
    })();
    const url = (() => {
        const match = note.match(regexp('url'));
        return match ? match[1].trim() : '';
    })();
    return { tag, date, url };
}

function getBody(nodes, parent){
    const result = []; // 本文を順々に入れていく配列
    roop(parent);

    function roop(node) {
        if(node.collapsed) return; // 畳んでいる場合は子項目を取得しない
        if(!node.children) return; // childrenプロパティが存在しない場合スキップ

        node.children.forEach(id => {
            const find = nodes.find(obj => obj.id === id);
            if(find) {
                result.push(find.content);
                roop(find);
            }
        })
    }

    return result.join('\n'); // 改行文字で繋げて文字列にする
}

fetch('https://dynalist.io/api/v1/doc/read', {
    method: 'POST', // methodにPOSTを指定する
    body: JSON.stringify({ // bodyにjson形式で情報を記述する
        token: '<secret token>', // Dynalistのサイトから取得したtokenを記述してください
        'file_id': '<file id>', // 対象となるドキュメントのidを記述してください
  }),
})
    .then(response => response.json())
    .then(data => {
        const articleNodes = getNodesByColor(data.nodes, 1);
        const result = articleNodes.map(obj => {
            const id = obj.id;
            const title = obj.content;
            const { tag, date, url } = getMetadata(obj.note);
            const body = getBody(data.nodes, obj);
            return { id, title, tag, date, url, body };
        })
        
        const json = JSON.stringify(result, null, '\t'); // インデントを反映した形で文字列にする
        Deno.writeTextFileSync('data.json', json); // ファイルに書き込む
    })

 ファイルシステムに関するメソッドはDenoオブジェクトに組み込まれているので、Node.jsでは必要だった冒頭のrequireは要りません。

 これをCLI(コマンドプロンプトなど)で実行します。

deno run -A getData.js

 -Aというのはパーミッションフラグというもので、ファイルの書き込みやネットワークアクセスなど全てを許可するという意味です。記述の順番には意味があるので、パーミッションフラグはファイル名の前に書いてください。

 


 これでデータをjsonファイルとして保存することができました。あとは公開するサイトのhtml内でこのjsonを読み込んでHTML要素を構築するコードを書いて完成です。

// 公開するhtmlで読み込むjsファイル

fetch('./data.json')
.then(res => res.json())
.then(data => {
    console.log(data);
    // HTML要素を構築するコードを書く
})

 

 一応この記事群は「Dynalist APIでDynalistのデータを活用可能にする」という趣旨のシリーズ記事なので、今回で「Noratetsu Houseの作り方」としては終わりになります。お疲れ様でした。


*1: Windowsでは、jsファイルがあるフォルダをエクスプローラで開いてアドレスバーに「cmd」と打ってEnterで、そのフォルダをカレントディレクトリにした状態でコマンドプロンプトが開きます(Windows以外については使ったことがないためわかりません)