• #Chrome拡張機能
  • #JavaScript
  • #仮想スクロール
  • #テーブル
  • #Web開発

Chrome拡張機能 - テーブルデータコピー機能の実装ドキュメント

対象ウェブサイト

URL: https://app.koyfin.com/estimates/eac/alt text

対象ページ: アナリストエスティメイツ(Analyst Estimates) > Actualページ

このドキュメントは、上記のKoyfinウェブサイトの財務データテーブルを、Excel/Googleスプレッドシートに効率的にコピーするためのChrome拡張機能の実装ガイドです。

概要

div要素で構成された複雑な財務データテーブルを、仮想スクロール環境下でも完全にコピーできるChrome拡張機能。

重要な注意点(他のAI向け)

1. HTML構造の特徴

最も重要: このテーブルは<table>タグではなく、div要素で構成されている

<div class="table-styles__table___fNvz7">
  <div class="table-styles__table__scrollContainer___WBAWY">
    <div class="table-styles__table__head___Uu9qm">
      <div class="table-styles__table__row___K6TSS">
        <div class="table-styles__table__headerCell___gC361">...</div>
      </div>
    </div>
  </div>
</div>

2. 仮想スクロール(Virtual Scrolling)の問題

重大な注意点: このテーブルは仮想スクロールを使用しているため、DOMには画面に表示されている要素だけが存在する

  • スクロールすると、古い要素がDOMから削除され、新しい要素が追加される
  • querySelectorAllで取得できるのは、現在表示されている要素のみ
  • 全データを取得するには、自動スクロールで全要素を強制的にロードする必要がある

仮想スクロールの特徴:

<!-- スクロールコンテナ -->
<div class="table-styles__table__scrollContainer___WBAWY" style="height: XXXpx;">
  <!-- 仮想的な高さを持つコンテナ -->
  <div style="width: 3080px; height: 4878px; position: relative;">
    <!-- 行は position: absolute で配置される -->
    <div class="table-styles__table__row___K6TSS" style="position: absolute; top: 318px;">
      ...
    </div>
  </div>
</div>

3. ヘッダーの複数行構造

重要: ヘッダーセルには、label要素ではなく、直接テキストノードとして改行で区切られたテキストが含まれている

<div class="table-styles__table__headerCell___gC361" style="left: 0px;">Fiscal Quarters

Period Ending

Report Date</div>

間違った実装例:

// ❌ これは動かない(label要素が存在しないため)
const labels = cell.querySelectorAll('label');

正しい実装例:

// ✅ textContentを取得して改行で分割
const text = cell.textContent.trim();
const lines = text.split('\n').map(line => line.trim()).filter(line => line);

4. セルの位置情報

各セルはposition: absoluteで配置されており、style.leftstyle.topで位置が指定されている。

<div class="table-styles__table__dataCell___nRZp0"
     style="position: absolute; left: 280px; top: 318px;">
  ...
</div>

重要: セルを正しい順序で並べるには、style.leftstyle.topの値でソートする必要がある。

実装の詳細

ファイル構成

chrome-extension-kofyin/
├── manifest.json          # 拡張機能の設定
├── content.js             # メインロジック
├── content.css            # スタイル
├── popup.html             # ポップアップUI
└── create-icons.html      # アイコン生成ツール

manifest.json

{
  "manifest_version": 3,
  "name": "Table Data Copier",
  "version": "1.0.0",
  "description": "ウェブページのテーブルデータを簡単にコピーできるChrome拡張機能",
  "permissions": [
    "activeTab",
    "clipboardWrite"
  ],
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content.js"],
      "css": ["content.css"]
    }
  ],
  "action": {
    "default_popup": "popup.html"
  }
}

注意: アイコン参照は、実際にアイコンファイルを作成するまで含めない。

content.js の核心実装

1. ヘッダーの複数行抽出

extractHeaderRows(headerRow) {
  const cellElements = Array.from(headerRow.children);

  // セルを位置順にソート(重要!)
  cellElements.sort((a, b) => {
    const leftA = parseInt(a.style.left) || 0;
    const leftB = parseInt(b.style.left) || 0;
    return leftA - leftB;
  });

  // 各セルのテキストを取得し、改行で分割
  const cellTexts = cellElements.map(cell => {
    // まずlabel要素を試す(別のテーブル形式に対応)
    const labels = cell.querySelectorAll('label');
    if (labels.length > 0) {
      const texts = Array.from(labels).map(label => label.textContent.trim()).filter(t => t);
      return texts.length > 0 ? texts : [''];
    }

    // label要素がない場合は、textContentを改行で分割(重要!)
    const text = cell.textContent.trim();
    const lines = text.split('\n').map(line => line.trim()).filter(line => line);
    return lines.length > 0 ? lines : [''];
  });

  // 最大行数を取得
  const maxRows = Math.max(...cellTexts.map(texts => texts.length));

  // 複数行のヘッダーを構築(転置)
  const headerRows = [];
  for (let i = 0; i < maxRows; i++) {
    const row = cellTexts.map(texts => texts[i] || '');
    headerRows.push(row);
  }

  return headerRows;
}

ポイント:

  • label要素の有無を確認(フォールバック対応)
  • textContentを改行で分割
  • 空行をフィルタリング
  • 転置処理で複数行のヘッダーを構築

2. 仮想スクロール対応の自動スクロール

async copyTableDataWithScroll(table, button) {
  const scrollContainer = table.querySelector('.table-styles__table__scrollContainer___WBAWY');

  if (!scrollContainer) {
    // スクロールコンテナがない場合は通常のコピー
    this.copyTableData(table);
    return;
  }

  // ボタンを無効化
  const originalText = button.textContent;
  button.disabled = true;
  button.textContent = '読み込み中...';

  try {
    // 全データを収集するためのMap(重複排除)
    const allItemsMap = new Map();

    // ヘッダー行を先に取得(複数行に分割)
    const headerRow = table.querySelector('.table-styles__table__head___Uu9qm .table-styles__table__row___K6TSS');
    let headerRows = [];
    if (headerRow) {
      headerRows = this.extractHeaderRows(headerRow);
    }

    // 現在のスクロール位置を保存
    const originalScrollTop = scrollContainer.scrollTop;

    // スクロールコンテナの高さ
    const containerHeight = scrollContainer.clientHeight;
    const scrollHeight = scrollContainer.scrollHeight;

    // スクロールしながらデータを収集
    let currentScroll = 0;
    const scrollStep = containerHeight / 2; // 半画面ずつスクロール

    while (currentScroll <= scrollHeight) {
      scrollContainer.scrollTop = currentScroll;

      // DOMが更新されるまで少し待つ(重要!)
      await new Promise(resolve => setTimeout(resolve, 100));

      // 現在表示されているグループヘッダーを収集
      const groupHeaders = scrollContainer.querySelectorAll('.table-styles__table__groupHeader___vPA4c');
      groupHeaders.forEach(groupHeader => {
        const topPosition = groupHeader.style.top;
        if (!topPosition) return;

        const groupText = this.extractTextContent(groupHeader);
        if (groupText && !allItemsMap.has(topPosition)) {
          allItemsMap.set(topPosition, {
            top: parseInt(topPosition),
            type: 'group',
            data: [groupText]
          });
        }
      });

      // 現在表示されているデータ行を収集
      const rows = scrollContainer.querySelectorAll('.table-styles__table__row___K6TSS');
      rows.forEach(row => {
        // ヘッダー行は除外
        if (row.closest('.table-styles__table__head___Uu9qm')) return;

        // 行の位置をキーとして使用(top位置で識別)
        const topPosition = row.style.top;
        if (!topPosition) return;

        // まだ収集していない行のみ追加
        if (!allItemsMap.has(topPosition)) {
          const rowData = this.extractRowData(row);
          if (rowData.length > 0 && rowData[0]) { // 空行を除外
            allItemsMap.set(topPosition, {
              top: parseInt(topPosition),
              type: 'row',
              data: rowData
            });
          }
        }
      });

      // 進捗を表示
      const progress = Math.min(100, Math.round((currentScroll / scrollHeight) * 100));
      button.textContent = `読み込み中... ${progress}%`;

      currentScroll += scrollStep;
    }

    // 元のスクロール位置に戻す
    scrollContainer.scrollTop = originalScrollTop;

    // 収集したデータをtop位置でソートして配列に変換
    const sortedItems = Array.from(allItemsMap.values())
      .sort((a, b) => a.top - b.top);

    // 最終的なデータ配列を構築
    const finalData = [];

    // ヘッダーを追加(複数行)
    if (headerRows && headerRows.length > 0) {
      headerRows.forEach(row => {
        finalData.push(row);
      });
    }

    // グループヘッダーとデータ行を位置順に追加
    sortedItems.forEach(item => {
      finalData.push(item.data);
    });

    // TSV形式に変換
    const tsv = finalData.map(row => row.join('\t')).join('\n');

    // クリップボードにコピー
    await navigator.clipboard.writeText(tsv);

    const dataRowCount = sortedItems.filter(item => item.type === 'row').length;
    button.textContent = originalText;
    button.disabled = false;
    this.showNotification(`テーブルデータをコピーしました!(${dataRowCount}行)`);

  } catch (error) {
    console.error('テーブルデータの抽出に失敗しました:', error);
    button.textContent = originalText;
    button.disabled = false;
    this.showNotification('エラーが発生しました', true);
  }
}

重要なポイント:

  1. Mapで重複排除: topPositionをキーとして使用
  2. await new Promise(resolve => setTimeout(resolve, 100)): DOMの更新を待つために必須
  3. 半画面ずつスクロール: containerHeight / 2で細かくスクロール
  4. 元の位置に復元: ユーザー体験のため
  5. top位置でソート: 正しい順序を保証

3. データ行の抽出

extractRowData(row) {
  const cells = [];

  // セルを左から右の順序で取得するため、position情報でソート
  const cellElements = Array.from(row.children);

  // セルを位置順にソート(重要!)
  cellElements.sort((a, b) => {
    const leftA = parseInt(a.style.left) || 0;
    const leftB = parseInt(b.style.left) || 0;
    return leftA - leftB;
  });

  cellElements.forEach(cell => {
    const text = this.extractTextContent(cell);
    cells.push(text);
  });

  return cells;
}

4. テキスト抽出

extractTextContent(element) {
  // label要素からテキストを抽出
  const labels = element.querySelectorAll('label');
  const texts = Array.from(labels).map(label => label.textContent.trim()).filter(t => t);

  // 改行でテキストを結合(ヘッダーセルの場合、複数行ある)
  return texts.join('\n');
}

注意: データセルにはlabel要素が含まれているが、ヘッダーセルには含まれていない場合がある。

トラブルシューティング

問題1: ヘッダーが正しく抽出されない

症状: ヘッダーが1行にまとまってしまう、または空になる

原因: label要素の有無を確認していない

解決策:

// label要素の確認を必ず行う
const labels = cell.querySelectorAll('label');
if (labels.length > 0) {
  // label要素がある場合
} else {
  // label要素がない場合はtextContentを使用
  const text = cell.textContent.trim();
  const lines = text.split('\n').map(line => line.trim()).filter(line => line);
}

問題2: 画面に表示されている部分だけしかコピーできない

症状: スクロールすると別のデータがコピーされる

原因: 仮想スクロールに対応していない

解決策: 自動スクロール機能を実装(上記のcopyTableDataWithScroll関数)

問題3: データの順序がバラバラ

症状: コピーしたデータの順序が正しくない

原因: セルの位置でソートしていない

解決策:

// 必ずleftとtopでソート
cellElements.sort((a, b) => {
  const leftA = parseInt(a.style.left) || 0;
  const leftB = parseInt(b.style.left) || 0;
  return leftA - leftB;
});

問題4: DOMの更新が間に合わない

症状: スクロールしてもデータが取得できない

原因: DOMの更新を待っていない

解決策:

// スクロール後に必ず待機
await new Promise(resolve => setTimeout(resolve, 100));

CSS クラス名の対応表

要素CSSクラス名
テーブルコンテナ.table-styles__table___fNvz7
スクロールコンテナ.table-styles__table__scrollContainer___WBAWY
ヘッダー領域.table-styles__table__head___Uu9qm
ヘッダーセル.table-styles__table__headerCell___gC361
データ行.table-styles__table__row___K6TSS
データセル.table-styles__table__dataCell___nRZp0
グループヘッダー.table-styles__table__groupHeader___vPA4c
固定列.table-styles__sticky___B0Ah9

出力形式

TSV(タブ区切り)形式で出力:

Fiscal Quarters 2Q FY2023A 3Q FY2023A 4Q FY2023A ...
Period Ending Apr-30-2023 Jul-31-2023 Oct-31-2023 ...
Report Date Jun-01-2023 Aug-31-2023 Dec-07-2023 ...
Income Statement
Sales 1000 1200 1500 ...
...

他のAI向けチェックリスト

Chrome拡張機能を実装する際は、以下を必ず確認してください:

  • ヘッダーセルがlabel要素を持っているか確認
  • 持っていない場合は、textContentを改行で分割
  • 仮想スクロールの有無を確認
  • スクロールコンテナが存在する場合は自動スクロール機能を実装
  • DOMの更新を待つためにawait new Promise(resolve => setTimeout(resolve, 100))を使用
  • セルをstyle.leftstyle.topでソート
  • Mapを使って重複を排除
  • 元のスクロール位置に戻す
  • TSV形式で出力(\tでセルを区切り、\nで行を区切る)

まとめ

この実装の核心は以下の3点:

  1. ヘッダーの複数行対応: label要素がない場合はtextContentを改行で分割
  2. 仮想スクロール対応: 自動スクロールで全データを強制的にロード
  3. 位置ベースのソート: style.leftstyle.topで正しい順序を保証

これらを理解すれば、同様のテーブルに対して確実に動作する拡張機能を作成できます。