Obsidian:ハイライト箇所を抜粋する

ツール活用

学生時代から膨大な文章から重要な箇所を抜粋する行為として、文書に蛍光ペンを引くといった行為をやってきただろう。

Obsidianを使うことで膨大なノートの膨大な文章と向き合うのに、ハイライトを効果的に使っていきたい。
Obsidianではマークダウン記法でハイライトも用意されているが、例えば上記プラグインを用いて色分けしたハイライトを引くといったこともできる。

しかしそのハイライト箇所を見返すのに、けっきょく膨大な文章をスクロールしまくるのは効率的ではない。

ではハイライト箇所だけ抽出すれば良いが、手動でやるより自動がイイなと思うのは当然の流れとなる。

ハイライト箇所を抽出するスクリプト

というわけで、Claude君に相談してTemplaterスクリプトを作成しました。

まず、次のようにハイライトを引いたノートがあります。
内容は ChatGPT君に「ケサランパサランをお題に宮沢賢治調で詩を作って」と適当に書いてもらったものです。

ケースサンプルのため、見出し、リスト、引用のハイライト例も入れました。

次にハイライト箇所を抽出する箇所にカーソルを置いて、スクリプトを実行します。

ハイライトした箇所がリストアップされました。

元のハイライトのブロック末端にブロック識別子が追加されていて、リストから内部リンクでジャンプすることができます。

ブロック末端の括りは「見出しやリストは各行末」「引用や通常段落はまとまった塊の末端」となります。
参照 : https://help.obsidian.md/links

リストアップを同じノートではなく、新規ノートに書き出す別のスクリプトも作成してみました。

こちらのスクリプトの場合は、元のノートのハイライト箇所にはブロック識別子が追加されます。

それとは別に上記の新規ノートが生成され、そこにハイライトがリストアップされます。
生成されるのは同じフォルダで、ノート名に「ハイライト抽出_」が追加されます。元のノートへのリンクも機能しています。

スクリプトサンプル

必要環境

ハイライト抽出リストアップ(同一ノート版)

<%*
// ハイライト箇所を抽出してブロックリンク付きリストを作成するスクリプト
// Obsidian 1.8.9 + Templater 2.10.0 対応

const editor = app.workspace.activeLeaf?.view?.editor;

if (!editor) {
    new Notice("エディターが見つかりません");
    return "";
}

// ノート全体のテキストを取得
let content = editor.getValue();
const lines = content.split('\n');

// 行の種類を判定する関数
function getLineType(line) {
    const trimmed = line.trim();

    if (trimmed === '') return 'empty';
    if (/^#{1,6}\s/.test(trimmed)) return 'heading';
    if (/^[-*+]\s/.test(trimmed)) return 'list';
    if (/^\d+\.\s/.test(trimmed)) return 'numbered-list';
    if (/^>\s/.test(trimmed)) return 'quote';

    return 'paragraph';
}

// ハイライトテキストを抽出する関数
function extractHighlights(line) {
    const matches = [];
    const pattern = /<mark class="hltr-[^"]*">(.*?)<\/mark>/g;
    let match;

    while ((match = pattern.exec(line)) !== null) {
        const text = match[1].trim();
        if (text) {
            matches.push(text);
        }
    }
    return matches;
}

// 次の行の種類を取得する関数
function getNextLineType(currentIndex, lines) {
    if (currentIndex + 1 < lines.length) {
        return getLineType(lines[currentIndex + 1]);
    }
    return 'end'; // ファイル終端
}

// ブロックが終了するかどうかを判定する関数
function isBlockEnd(currentLineType, nextLineType) {
    // 見出し、リスト、番号リストは常に単独ブロック
    if (currentLineType === 'heading' || 
        currentLineType === 'list' || 
        currentLineType === 'numbered-list') {
        return true;
    }

    // 引用または段落の場合
    if (currentLineType === 'quote' || currentLineType === 'paragraph') {
        // 次の行が異なる種類、空行、またはファイル終端ならブロック終了
        return nextLineType !== currentLineType;
    }

    return false;
}

// メイン処理
const modifiedLines = [...lines];
const allHighlights = [];
let addedBlockIds = 0;

// ブロックID用「月日」
const now = new Date();
const mmdd = String(now.getMonth() + 1).padStart(2, '0') + String(now.getDate()).padStart(2, '0');

// 現在のブロック内のハイライトを追跡
let currentBlockHighlights = [];

// 全行をチェック
for (let i = 0; i < lines.length; i++) {
    const currentLine = lines[i];
    const currentLineType = getLineType(currentLine);

    // 空行はスキップ
    if (currentLineType === 'empty') {
        continue;
    }

    // 現在行のハイライトを抽出
    const highlights = extractHighlights(currentLine);

    // ハイライトがあれば現在ブロックに追加
    if (highlights.length > 0) {
        currentBlockHighlights.push(...highlights);
    }

    // 次の行の種類を取得
    const nextLineType = getNextLineType(i, lines);

    // ブロック終了判定
    if (isBlockEnd(currentLineType, nextLineType)) {
        // 現在のブロックにハイライトがある場合
        if (currentBlockHighlights.length > 0) {
            // 既にブロックIDがあるかチェック
            const hasBlockId = /\s\^[\w-]+\s*$/.test(currentLine);

            let blockId;
            if (hasBlockId) {
                // 既存のブロックIDを抽出
                const blockIdMatch = currentLine.match(/\s\^([\w-]+)\s*$/);
                blockId = blockIdMatch ? blockIdMatch[1] : `hl-${mmdd}-${addedBlockIds}`;
            } else {
                // 新しいブロックIDを生成して付与
                blockId = `hl-${mmdd}-${addedBlockIds}`;
                modifiedLines[i] = currentLine + ` ^${blockId}`;
                addedBlockIds++;
            }

            // このブロックのハイライトをリストに追加
            currentBlockHighlights.forEach(text => {
                allHighlights.push({
                    text: text,
                    blockId: blockId
                });
            });
        }

        // 現在ブロックのハイライトをリセット
        currentBlockHighlights = [];
    }
}

// ハイライトが見つからない場合
if (allHighlights.length === 0) {
    new Notice("ハイライトが見つかりませんでした");
    return "";
}

// 現在のカーソル位置を事前に取得
const originalCursor = editor.getCursor();

// ブロック識別子を追加した場合、ノート内容を更新
if (addedBlockIds > 0) {
    const modifiedContent = modifiedLines.join('\n');
    editor.setValue(modifiedContent);
    // カーソル位置を元の位置に戻す
    editor.setCursor(originalCursor);
}

// ブロックリンク付きリスト形式でフォーマット
let highlightList = "## ハイライト抽出\n\n";
allHighlights.forEach((highlight) => {
    highlightList += `- [${highlight.text}](#^${highlight.blockId})\n`;
});

// 現在のカーソル位置に挿入
editor.replaceRange(highlightList, originalCursor);

// 完了通知
const message = addedBlockIds > 0 
    ? `✅ ${allHighlights.length}個のハイライトを抽出し、${addedBlockIds}個のブロックIDを追加しました`
    : `✅ ${allHighlights.length}個のハイライトを抽出しました`;
new Notice(message);

return "";
%>

ハイライト抽出リストアップ(新規ノート版)

<%*
// ハイライト箇所を抽出して新規ノートにブロックリンク付きリストを作成するスクリプト
// Obsidian 1.8.9 + Templater 2.10.0 対応
// 新規ノート作成版

// より確実なエディターとファイル取得
const activeLeaf = app.workspace.activeLeaf;
const activeFile = app.workspace.getActiveFile();

if (!activeLeaf?.view?.editor || !activeFile) {
    new Notice("❌ アクティブなファイルまたはエディターが見つかりません");
    return "";
}

const editor = activeLeaf.view.editor;

// 現在のファイル情報を取得
const originalFilePath = activeFile.path;
const originalFileName = activeFile.basename;
const folderPath = activeFile.parent?.path || "";

// ファイル名を安全な形式に変換する関数
function sanitizeFileName(fileName) {
    // Windows/Mac/Linuxで使用できない文字を置換
    return fileName
        .replace(/[<>:"/\\|?*]/g, '_')  // 禁止文字を_に置換
        .replace(/\.$/, '_')           // 末尾のピリオドを_に置換
        .slice(0, 200);                // 長すぎる場合は切り詰め
}

// パスを正規化する関数
function normalizePath(path) {
    // 先頭の/を除去し、再度/を付けて正規化
    return '/' + path.replace(/^\/+/, '').replace(/\/+/g, '/');
}

// 一意なブロックIDを生成する関数
function generateUniqueBlockId(baseId, existingIds) {
    let counter = 0;
    let uniqueId = baseId;
    while (existingIds.has(uniqueId)) {
        uniqueId = `${baseId}-${counter}`;
        counter++;
    }
    existingIds.add(uniqueId);
    return uniqueId;
}

// ノート全体のテキストを取得
let content = editor.getValue();
const lines = content.split('\n');

// 行の種類を判定する関数
function getLineType(line) {
    const trimmed = line.trim();

    if (trimmed === '') return 'empty';
    if (/^#{1,6}\s/.test(trimmed)) return 'heading';
    if (/^[-*+]\s/.test(trimmed)) return 'list';
    if (/^\d+\.\s/.test(trimmed)) return 'numbered-list';
    if (/^>\s/.test(trimmed)) return 'quote';

    return 'paragraph';
}

// ハイライトテキストを抽出する関数(より柔軟なパターン)
function extractHighlights(line) {
    const matches = [];
    // より柔軟なハイライトパターン(class名の変更に対応)
    const patterns = [
        /<mark class="hltr-[^"]*">(.*?)<\/mark>/g,  // 現在の形式
        /<mark[^>]*class="[^"]*hltr[^"]*"[^>]*>(.*?)<\/mark>/g,  // より柔軟な形式
        /==(.*?)==/g  // マークダウン形式のハイライト
    ];

    patterns.forEach(pattern => {
        let match;
        while ((match = pattern.exec(line)) !== null) {
            const text = match[1].trim();
            if (text && !matches.includes(text)) {  // 重複排除
                matches.push(text);
            }
        }
    });

    return matches;
}

// 次の行の種類を取得する関数
function getNextLineType(currentIndex, lines) {
    if (currentIndex + 1 < lines.length) {
        return getLineType(lines[currentIndex + 1]);
    }
    return 'end'; // ファイル終端
}

// ブロックが終了するかどうかを判定する関数
function isBlockEnd(currentLineType, nextLineType) {
    // 見出し、リスト、番号リストは常に単独ブロック
    if (currentLineType === 'heading' || 
        currentLineType === 'list' || 
        currentLineType === 'numbered-list') {
        return true;
    }

    // 引用または段落の場合
    if (currentLineType === 'quote' || currentLineType === 'paragraph') {
        // 次の行が異なる種類、空行、またはファイル終端ならブロック終了
        return nextLineType !== currentLineType;
    }

    return false;
}

// メイン処理
const allHighlights = [];
let needsUpdate = false;
const usedBlockIds = new Set();

// ブロックID用タイムスタンプ(より一意性を高める)
const now = new Date();
const timestamp = String(now.getMonth() + 1).padStart(2, '0') + 
                 String(now.getDate()).padStart(2, '0') + 
                 String(now.getHours()).padStart(2, '0') + 
                 String(now.getMinutes()).padStart(2, '0');

// 既存のブロックIDを事前に収集
lines.forEach(line => {
    const blockIdMatch = line.match(/\s\^([\w-]+)\s*$/);
    if (blockIdMatch) {
        usedBlockIds.add(blockIdMatch[1]);
    }
});

// 現在のブロック内のハイライトを追跡
let currentBlockHighlights = [];
const modifiedLines = [];

// 全行をチェック
for (let i = 0; i < lines.length; i++) {
    const currentLine = lines[i];
    const currentLineType = getLineType(currentLine);
    modifiedLines.push(currentLine);

    // 空行はスキップ
    if (currentLineType === 'empty') {
        continue;
    }

    // 現在行のハイライトを抽出
    const highlights = extractHighlights(currentLine);

    // ハイライトがあれば現在ブロックに追加
    if (highlights.length > 0) {
        currentBlockHighlights.push(...highlights);
    }

    // 次の行の種類を取得
    const nextLineType = getNextLineType(i, lines);

    // ブロック終了判定
    if (isBlockEnd(currentLineType, nextLineType)) {
        // 現在のブロックにハイライトがある場合
        if (currentBlockHighlights.length > 0) {
            // 既にブロックIDがあるかチェック
            const hasBlockId = /\s\^[\w-]+\s*$/.test(currentLine);

            let blockId;
            if (hasBlockId) {
                // 既存のブロックIDを抽出
                const blockIdMatch = currentLine.match(/\s\^([\w-]+)\s*$/);
                blockId = blockIdMatch[1];
            } else {
                // 新しい一意なブロックIDを生成
                const baseBlockId = `hl-${timestamp}`;
                blockId = generateUniqueBlockId(baseBlockId, usedBlockIds);
                modifiedLines[i] = currentLine + ` ^${blockId}`;
                needsUpdate = true;
            }

            // このブロックのハイライトをリストに追加(重複排除)
            const uniqueHighlights = [...new Set(currentBlockHighlights)];
            uniqueHighlights.forEach(text => {
                allHighlights.push({
                    text: text,
                    blockId: blockId
                });
            });
        }

        // 現在ブロックのハイライトをリセット
        currentBlockHighlights = [];
    }
}

// ハイライトが見つからない場合
if (allHighlights.length === 0) {
    new Notice("⚠️ ハイライトが見つかりませんでした");
    return "";
}

// ブロック識別子を追加した場合、元のノート内容を更新
if (needsUpdate) {
    try {
        const modifiedContent = modifiedLines.join('\n');
        editor.setValue(modifiedContent);
    } catch (error) {
        new Notice(`❌ 元のノートの更新に失敗しました: ${error.message}`);
        return "";
    }
}

// 新規ノートのファイル名を安全な形式で生成
const safeOriginalFileName = sanitizeFileName(originalFileName);
const newFileName = sanitizeFileName(`ハイライト抽出_${safeOriginalFileName}`);
const newFilePath = folderPath ? `${folderPath}/${newFileName}.md` : `${newFileName}.md`;

// 正規化されたパスでブロックリンクを生成
const normalizedOriginalPath = normalizePath(originalFilePath.replace(/\.md$/, ''));

// ブロックリンク付きリスト形式でフォーマット
let highlightList = `# ハイライト抽出:${originalFileName}\n\n`;
highlightList += `**元ノート**: [[${originalFileName}]]\n`;
highlightList += `**作成日時**: ${new Date().toLocaleString('ja-JP')}\n`;
highlightList += `**ハイライト数**: ${allHighlights.length}個\n\n`;
highlightList += "## 📋 ハイライト一覧\n\n";

allHighlights.forEach((highlight, index) => {
    // 絶対パスでのブロックリンクを作成
    highlightList += `${index + 1}. [${highlight.text}](${normalizedOriginalPath}.md#^${highlight.blockId})\n`;
});

try {
    // 既存ファイルのチェック
    const existingFile = app.vault.getAbstractFileByPath(newFilePath);
    if (existingFile) {
        const timestamp = new Date().toISOString().slice(11, 19).replace(/:/g, '');
        const uniqueFileName = sanitizeFileName(`ハイライト抽出_${safeOriginalFileName}_${timestamp}`);
        const uniqueFilePath = folderPath ? `${folderPath}/${uniqueFileName}.md` : `${uniqueFileName}.md`;

        await app.vault.create(uniqueFilePath, highlightList);

        // 新規作成したノートを開く(少し待機してから)
        setTimeout(async () => {
            const newFile = app.vault.getAbstractFileByPath(uniqueFilePath);
            if (newFile) {
                await app.workspace.getLeaf().openFile(newFile);
            }
        }, 100);

        new Notice(`✅ ${allHighlights.length}個のハイライトを抽出\n📄 「${uniqueFileName}」を作成しました(重複回避)`);
    } else {
        // 新規ノートを作成
        await app.vault.create(newFilePath, highlightList);

        // 新規作成したノートを開く(少し待機してから)
        setTimeout(async () => {
            const newFile = app.vault.getAbstractFileByPath(newFilePath);
            if (newFile) {
                await app.workspace.getLeaf().openFile(newFile);
            }
        }, 100);

        const updateMessage = needsUpdate ? `、${usedBlockIds.size}個のブロックIDを追加` : '';
        new Notice(`✅ ${allHighlights.length}個のハイライトを抽出${updateMessage}\n📄 「${newFileName}」を作成しました`);
    }

} catch (error) {
    new Notice(`❌ ノートの作成に失敗しました: ${error.message}`);
    console.error('ハイライト抽出エラー:', error);
}

return "";
%>

作成したスクリプトはTemplaterのホットキーに登録して、ホットキーが使えるようにしましょう。