🌿
🌿 MidoriPhotoArt.
形態素解析ライブラリkuromoji.jsを使用して、CSVファイルからデータを取得し、キーワードごとに分類されたインデックスを作成して表示する。Jsonファイルとしてダウンロードできる。WEBサーバ上だと通常動作しないのですが、.htaccessを使用して、ローカル環境でも動作するようにしてあります。
索引

kuromoji.jsについて

kuromoji.jsは、日本語の形態素解析を行うためのJavaScriptライブラリです。 形態素解析とは、自然言語で書かれた文を形態素(意味を持つ最小の単位)に分割し、それぞれの品詞を特定する処理のことです。 kuromoji.jsは、辞書データを内蔵しており、日本語のテキストを精度良く解析することができます。

主な特徴:
  • 純粋なJavaScriptで実装されており、ブラウザやNode.jsなどの様々な環境で動作します。
  • 辞書データを内蔵しているため、外部の辞書ファイルを必要としません。
  • 高速かつ軽量な動作を実現しています。
  • シンプルで使いやすいAPIを提供しています。

ライセンス:
kuromoji.jsは、Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) の下で公開されています。

    
                                     Apache License
                               Version 2.0, January 2004
                            http://www.apache.org/licenses/

                    

詳細情報:
kuromoji.jsの詳細については、公式サイト をご覧ください。

.htaccessによる設定

kuromoji.jsを使用する際には、辞書データが必要となります。辞書データは、効率的なデータ転送のために通常.gz形式で圧縮されています。
しかし、一部のウェブサーバーでは、.gzファイルを転送する際に自動的に解凍してしまう設定になっている場合があります。この自動解凍機能が有効になっていると、kuromoji.jsが辞書データを正しく読み込めず、エラーが発生する原因となります。
この問題を解決するため、ウェブサーバー側で.gzファイルの自動解凍を無効にする設定を行いました。具体的には、.htaccessファイルに以下の設定を追加しました。

<FilesMatch "\.gz$">
  AddEncoding identity .gz
  SetEnv no-gzip 1
</FilesMatch>
                    
この設定により、.gzファイルは圧縮されたままクライアントに送信されるようになります。各ディレクティブの詳細は以下の通りです:
  • <FilesMatch "\.gz$">: このディレクティブは、.gzで終わるファイル名にマッチします。
  • AddEncoding identity .gz: このディレクティブは、.gzファイルに対して"identity"エンコーディングを指定します。これにより、サーバーはファイルを変更せずにそのまま送信します。
  • SetEnv no-gzip 1: このディレクティブは、"no-gzip"環境変数を設定します。これにより、サーバーは.gzファイルを圧縮せずに送信します。
この設定変更により、kuromoji.jsは.gz形式の辞書データを正しく読み込めるようになり、エラーが解消されました。

データ処理と表示機能

機能概要

このプログラムは、CSVファイルからデータを読み込み、キーワードに基づいて記事を分類・表示する機能を提供します。 ユーザーはキーワードを選択することで、関連する記事を一覧表示できます。また、データのダウンロード機能も備えています。

主要な処理の流れ

  1. データの読み込み:
    • CSVファイルからデータを非同期で読み込みます。
    • 読み込み中は「データを読み込んでいます...」というメッセージを表示します。
    • 未来の日付の記事は除外されます。
  2. キーワードの抽出:
    • 各記事の説明文からキーワードを抽出します。
    • キーワードは2語以上の組み合わせで構成されます。
    • 出現回数が少ないキーワードは除外されます。
  3. キーワードリストの表示:
    • 抽出されたキーワードと、それに関連する記事数が表示されます。
    • キーワードをクリックすると、関連する記事が一覧表示されます。
  4. 記事の表示:
    • 選択されたキーワードに関連する記事が一覧表示されます。
    • 記事のタイトル、リンク(存在する場合)、公開日が表示されます。
  5. データのダウンロード:
    • 分類されたデータをJSON形式でダウンロードできます。
このデータの流れを図で表すと、以下のようになります。
データ読み込み CSVファイル 非同期処理 キーワード抽出 形態素解析 出現頻度 キーワードリスト表示 関連記事数 クリックで記事表示 記事表示 タイトル、リンク 公開日 データダウンロード JSON形式

関数の詳細

createCategorizedIndexFromCSV()

この関数の処理フローは以下の通りです。
  1. CSVファイルからデータを非同期で読み込み、キーワードごとに分類します。
    
                    /**
                     * CSVファイルからデータを読み込み、キーワードで分類してインデックスを作成する非同期関数。
                     * 処理中はローディングメッセージを表示し、完了後にキーワードリスト、ダウンロードボタン、未来の日付の記事数を表示する。
                     * エラーが発生した場合はエラーメッセージを表示する。
                     */
                    async function createCategorizedIndexFromCSV() {
                        // ローディングメッセージの表示
                        displayLoadingMessage();
    
                        // CSVファイルからデータを非同期で読み込む
                        const rows = await fetchDataFromCSV('../data.csv');
    
                        // 今日の日付を取得し、時間をリセット
                        const today = resetTimeToMidnight(new Date());
    
                        // 今日の日付以前のデータのみをフィルタリング
                        const filteredRows = filterRowsByDate(rows, today);
    
                        // キーワードマップと未分類記事の初期化
                        let { keywordMap, uncategorizedArticles } = initializeKeywordMapping();
    
                        // 形態素解析器をビルドし、キーワードを抽出
                        keywordMap = await buildTokenizerAndExtractKeywords(filteredRows, keywordMap, uncategorizedArticles);
    
                        // キーワードマップから記事数が5未満のキーワードを削除
                        keywordMap = filterKeywordsByArticleCount(keywordMap, 5);
    
                        // 未分類の記事を「その他」カテゴリに追加
                        keywordMap = addUncategorizedArticlesToKeywordMap(keywordMap, uncategorizedArticles);
    
                        // 分類されたデータを設定
                        categorizedData = keywordMap;
    
                        // インデックスコンテナをクリア
                        clearIndexContainer();
    
                        // キーワードリストを作成し、イベントリスナーを追加
                        const keywordsList = createKeywordsList(keywordMap);
    
                        // インデックスコンテナにキーワードリストを追加
                        appendKeywordsListToIndexContainer(keywordsList);
    
                        // ダウンロードボタンを作成し、イベントリスナーを追加
                        const downloadButton = createDownloadButton(categorizedData);
    
                        // インデックスコンテナにダウンロードボタンを追加
                        appendDownloadButtonToIndexContainer(downloadButton);
    
                        // 未来の日付の記事数を計算し、表示
                        displayFutureArticlesCount(rows, filteredRows);
    
                        // ローディングメッセージを削除
                        removeLoadingMessage();
                    }
    
                    /**
                     * ローディングメッセージを表示する。
                     */
                    function displayLoadingMessage() {
                        const loadingMessage = document.createElement('div');
                        loadingMessage.id = 'loading-message';
                        loadingMessage.textContent = 'データを読み込んでいます...';
                        loadingMessage.style.cssText = 'text-align: center; margin-top: 20px; font-family: "Yu Mincho", serif;';
                        document.getElementById('content-display').appendChild(loadingMessage);
                    }
    
                    /**
                     * CSVファイルからデータを非同期で読み込む。
                     * @param {string} filePath - CSVファイルのパス
                     * @returns {Promise<Array<Array<string>>>} - CSVデータの行の配列
                     */
                    async function fetchDataFromCSV(filePath) {
                        const response = await fetch(filePath, { cache: 'no-cache' });
                        const data = await response.text();
                        return data.split('\n').map(row => row.split(',')).filter(row => row.length > 1 && row[0]);
                    }
    
                    /**
                     * 日付の時間を00:00:00にリセットする。
                     * @param {Date} date - リセットする日付
                     * @returns {Date} - 時間がリセットされた日付
                     */
                    function resetTimeToMidnight(date) {
                        date.setHours(0, 0, 0, 0);
                        return date;
                    }
    
                    /**
                     * 今日の日付以前のデータのみをフィルタリングする。
                     * @param {Array<Array<string>>>} rows - CSVデータの行の配列
                     * @param {Date} today - 今日の日付
                     * @returns {Array<Array<string>>>} - フィルタリングされた行の配列
                     */
                    function filterRowsByDate(rows, today) {
                        return rows.filter(row => {
                            const rowDate = new Date(row[2]);
                            return rowDate <= today;
                        });
                    }
    
                    /**
                     * キーワードマップと未分類記事を初期化する。
                     * @returns {{ keywordMap: {}, uncategorizedArticles: [] }} - キーワードマップと未分類記事のオブジェクト
                     */
                    function initializeKeywordMapping() {
                        return { keywordMap: {}, uncategorizedArticles: [] };
                    }
    
                    /**
                     * 形態素解析器をビルドし、キーワードを抽出する。
                     * @param {Array<Array<string>>>} filteredRows - フィルタリングされた行の配列
                     * @param {{}} keywordMap - キーワードマップ
                     * @param {[]} uncategorizedArticles - 未分類記事の配列
                     * @returns {Promise<{}>} - 更新されたキーワードマップ
                     */
                    async function buildTokenizerAndExtractKeywords(filteredRows, keywordMap, uncategorizedArticles) {
                        return new Promise((resolve) => {
                            kuromoji.builder({ dicPath: "https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/" }).build(function (err, tokenizer) {
                                filteredRows.forEach(row => {
                                    const description = row[3];
                                    const tokens = tokenizer.tokenize(description);
                                    const keywords = tokens.filter(token => ['名詞', '形容詞', '動詞'].includes(token.pos))
                                        .map(token => token.surface_form)
                                        .filter(word => word.length > 1);
    
                                    let keywordFound = false;
                                    if (keywords.length > 0) {
                                        for (let i = 0; i <= keywords.length - 2; i++) {
                                            const combinedKeyword = keywords.slice(i, i + 2).join(' ');
                                            if (!keywordMap[combinedKeyword]) {
                                                keywordMap[combinedKeyword] = [];
                                            }
                                            keywordMap[combinedKeyword].push(row);
                                            keywordFound = true;
                                        }
                                    }
    
                                    if (!keywordFound) {
                                        uncategorizedArticles.push(row);
                                    }
                                });
                                resolve(keywordMap);
                            });
                        });
                    }
    
                    /**
                     * キーワードマップから記事数が指定数未満のキーワードを削除する。
                     * @param {{}} keywordMap - キーワードマップ
                     * @param {number} minCount - 最小記事数
                     * @returns {{}} - 更新されたキーワードマップ
                     */
                    function filterKeywordsByArticleCount(keywordMap, minCount) {
                        for (const keyword in keywordMap) {
                            if (keywordMap[keyword].length < minCount) {
                                delete keywordMap[keyword];
                            }
                        }
                        return keywordMap;
                    }
    
                    /**
                     * 未分類の記事をキーワードマップに追加する。
                     * @param {{}} keywordMap - キーワードマップ
                     * @param {[]} uncategorizedArticles - 未分類記事の配列
                     * @returns {{}} - 更新されたキーワードマップ
                     */
                    function addUncategorizedArticlesToKeywordMap(keywordMap, uncategorizedArticles) {
                        if (uncategorizedArticles.length > 0) {
                            keywordMap['その他'] = uncategorizedArticles;
                        }
                        return keywordMap;
                    }
    
                    /**
                     * インデックスコンテナをクリアする。
                     */
                    function clearIndexContainer() {
                        const indexContainer = document.getElementById('content-display');
                        indexContainer.innerHTML = '';
                    }
    
                    /**
                     * キーワードリストを作成し、イベントリスナーを追加する。
                     * @param {{}} keywordMap - キーワードマップ
                     * @returns {HTMLElement} - キーワードリストのul要素
                     */
                    function createKeywordsList(keywordMap) {
                        const keywordsList = document.createElement('ul');
                        keywordsList.id = 'keywords-list';
                        keywordsList.style.listStyle = 'none';
                        keywordsList.style.padding = '0';
                        keywordsList.style.marginBottom = '20px';
                        keywordsList.style.display = 'flex';
                        keywordsList.style.flexWrap = 'wrap';
                        keywordsList.style.gap = '10px';
    
                        for (const keyword in keywordMap) {
                            const count = keywordMap[keyword].length;
                            const keywordItem = document.createElement('li');
                            keywordItem.textContent = `${keyword} (${count})`;
                            keywordItem.style.backgroundColor = '#f0f0f0';
                            keywordItem.style.padding = '5px 10px';
                            keywordItem.style.borderRadius = '5px';
                            keywordItem.style.cursor = 'pointer';
                            keywordItem.style.fontFamily = "'Yu Mincho", serif";
                            keywordItem.style.fontWeight = 'bold';
    
                            keywordItem.addEventListener('click', () => {
                                displayArticlesByKeyword(keyword, keywordMap);
                            });
    
                            keywordsList.appendChild(keywordItem);
                        }
    
                        return keywordsList;
                    }
    
                    /**
                     * インデックスコンテナにキーワードリストを追加する。
                     * @param {HTMLElement} keywordsList - キーワードリストのul要素
                     */
                    function appendKeywordsListToIndexContainer(keywordsList) {
                        const indexContainer = document.getElementById('content-display');
                        indexContainer.appendChild(keywordsList);
                    }
    
                    /**
                     * ダウンロードボタンを作成し、イベントリスナーを追加する。
                     * @param {{}} categorizedData - 分類されたデータ
                     * @returns {HTMLElement} - ダウンロードボタンのbutton要素
                     */
                    function createDownloadButton(categorizedData) {
                        const downloadButton = document.createElement('button');
                        downloadButton.textContent = 'データをダウンロード';
                        downloadButton.style.cssText = 'margin-top: 20px; padding: 10px 20px; background-color: #4CAF50; color: white; border: none; border-radius: 5px; cursor: pointer; font-family: "Yu Mincho", serif;';
                        downloadButton.addEventListener('click', () => {
                            downloadCategorizedData(categorizedData);
                        });
                        return downloadButton;
                    }
    
                    /**
                     * インデックスコンテナにダウンロードボタンを追加する。
                     * @param {HTMLElement} downloadButton - ダウンロードボタンのbutton要素
                     */
                    function appendDownloadButtonToIndexContainer(downloadButton) {
                        const indexContainer = document.getElementById('content-display');
                        indexContainer.appendChild(downloadButton);
                    }
    
                    /**
                     * 未来の日付の記事数を計算し、表示する。
                     * @param {Array<Array<string>>>} rows - CSVデータの行の配列
                     * @param {Array<Array<string>>>} filteredRows - フィルタリングされた行の配列
                     */
                    function displayFutureArticlesCount(rows, filteredRows) {
                        const hiddenArticlesCount = rows.length - filteredRows.length;
                        if (hiddenArticlesCount > 0) {
                            const hiddenArticlesMessage = document.createElement('p');
                            hiddenArticlesMessage.textContent = `※現在、未来の日付の記事が ${hiddenArticlesCount} 件あります。`;
                            hiddenArticlesMessage.style.marginTop = '10px';
                            hiddenArticlesMessage.style.fontFamily = "'Yu Mincho", serif";
                            const indexContainer = document.getElementById('content-display');
                            indexContainer.appendChild(hiddenArticlesMessage);
                        }
                    }
    
                    /**
                     * ローディングメッセージを削除する。
                     */
                    function removeLoadingMessage() {
                        const loadingMessage = document.getElementById('loading-message');
                        if (loadingMessage && loadingMessage.parentNode) {
                            loadingMessage.parentNode.removeChild(loadingMessage);
                        }
                    }
                                
  2. キーワードリストとダウンロードボタンを表示します。
  3. 未来の日付の記事数を表示します。
  4. データの読み込み中にエラーが発生した場合は、エラーメッセージを表示します。

displayArticlesByKeyword(keyword, keywordMap)

この関数の処理フローは以下の通りです。
  1. コンテナの準備:
    • 指定されたキーワードに関連する記事を一覧表示するためのコンテナ(div要素)を準備します。
    • 既に記事が表示されている場合は、既存の記事を削除して新しいコンテナを作成します。
    • コンテナには、スタイル(余白、枠線、パディング、影)が設定されます。
    
                    // 記事を表示するためのdiv要素を作成
                    articlesDiv = document.createElement('div');
                    articlesDiv.id = 'articles-container';
                    articlesDiv.classList.add('articles-div');
                    articlesDiv.style.cssText = 'margin: 10px 0 20px; border: 1px solid #ccc; border-radius: 8px; padding: 10px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);';
                                
  2. キーワードに関連する記事データの取得:
    • keywordMapオブジェクトから、指定されたキーワードに関連する記事データ(行の配列)を取得します。
    
                    // displayArticlesByKeyword関数の引数としてkeywordMapが渡される
                    function displayArticlesByKeyword(keyword, keywordMap) {
                        // ...
                        // 重複する記事を除外
                        const uniqueRows = [...new Set(keywordMap[keyword])];
                        // ...
                    }
                                
  3. 重複記事の除外:
    • 記事データには重複が含まれている可能性があるため、Setオブジェクトを使用して重複を除外します。
    
                    // 重複する記事を除外
                    const uniqueRows = [...new Set(keywordMap[keyword])];
                                
  4. 記事の表示:
    • 各記事のタイトル、リンク(存在する場合)、公開日を表示します。
    • 記事のタイトルはh4要素で表示され、スタイル(フォント、太さ、余白、色、サイズ)が設定されます。
    • 記事リストはul要素で表示され、各記事はli要素で表示されます。
    • li要素には、タイトル、リンク、公開日が含まれ、スタイル(余白、枠線、パディング)が設定されます。
    • リンクが存在する場合は、a要素で表示され、新しいタブで開くように設定されます。
    • リンクが存在しない場合は、span要素でタイトルが表示されます。
    • 公開日は、タイトルの右側に小さく表示されます。
    
                    // キーワードの記事の見出しを作成
                    const keywordTitle = document.createElement('h4');
                    keywordTitle.textContent = `「${keyword}」の記事`;
                    keywordTitle.style.cssText = 'font-family: "Yu Mincho", serif; font-weight: bold; margin: 0 0 5px; color: #333; font-size: 1em;';
                    articlesDiv.appendChild(keywordTitle);
    
                    // 記事を表示するためのul要素を作成
                    const ul = document.createElement('ul');
                    ul.style.cssText = 'list-style: none; padding: 0; font-family: "Yu Gothic", "Meiryo", sans-serif;';
    
                    // 各記事のli要素を作成し、ul要素に追加
                    uniqueRows.forEach(row => {
                        const li = document.createElement('li');
                        li.style.cssText = 'margin-bottom: 8px; border-bottom: 1px solid #eee; padding-bottom: 8px;';
    
                        // リンク要素またはスパン要素を作成
                        let linkElement = row[1] ? `<a href="${row[1]}" target="_blank" style="text-decoration: none; color: #0077cc; font-weight: bold; font-size: 1em;">${row[3]}</a>` : `<span style="font-weight: bold; font-size: 1em;">${row[3]}</span>`;
    
                        // li要素のHTMLを設定
                        li.innerHTML = `
                            <div style="display: flex; align-items: center; justify-content: space-between; font-size: 0.9em;">
                                ${linkElement}
                                <div style="color: #777; font-size: 0.8em;">${row[2]}</div>
                            </div>
                        `;
                        // エスケープ処理を追加
                        li.innerHTML = li.innerHTML.replace(/</g, '&lt;').replace(/>/g, '&gt;');
                        ul.appendChild(li);
                    });
    
                    // 記事のdiv要素にul要素を追加
                    articlesDiv.appendChild(ul);
                                
  5. エラー処理:
    • 記事の読み込み中にエラーが発生した場合は、エラーメッセージを表示します。
    • エラーメッセージは、p要素で表示され、赤色で表示されます。
    
                    } catch (error) {
                        console.error('エラー:', error);
                        const indexContainer = document.getElementById('content-display');
                        if (indexContainer) {
                            const errorMessage = document.createElement('p');
                            errorMessage.textContent = error.message || '記事の読み込みに失敗しました。';
                            errorMessage.style.color = 'red';
                            indexContainer.appendChild(errorMessage);
                        }
                    }
                                

使用技術

  • JavaScript: データの読み込み、キーワード抽出、表示処理に使用
  • Fetch API: CSVファイルの非同期読み込みに使用
  • kuromoji.js: 日本語の形態素解析に使用
  • DOM操作: キーワードリストや記事リストの動的な生成に使用

注意事項

  • データの読み込みには時間がかかる場合があります。
  • 未来の日付の記事は表示されません。
  • エラーが発生した場合は、エラーメッセージが表示されます。