Chrome拡張機能 - テーブルデータコピー機能の実装ドキュメント
対象ウェブサイト
URL: https://app.koyfin.com/estimates/eac/
対象ページ: アナリストエスティメイツ(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.leftとstyle.topで位置が指定されている。
<div class="table-styles__table__dataCell___nRZp0"
style="position: absolute; left: 280px; top: 318px;">
...
</div>
重要: セルを正しい順序で並べるには、style.leftとstyle.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);
}
}
重要なポイント:
- Mapで重複排除:
topPositionをキーとして使用 - await new Promise(resolve => setTimeout(resolve, 100)): DOMの更新を待つために必須
- 半画面ずつスクロール:
containerHeight / 2で細かくスクロール - 元の位置に復元: ユーザー体験のため
- 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.leftとstyle.topでソート - Mapを使って重複を排除
- 元のスクロール位置に戻す
- TSV形式で出力(
\tでセルを区切り、\nで行を区切る)
まとめ
この実装の核心は以下の3点:
- ヘッダーの複数行対応: label要素がない場合はtextContentを改行で分割
- 仮想スクロール対応: 自動スクロールで全データを強制的にロード
- 位置ベースのソート:
style.leftとstyle.topで正しい順序を保証
これらを理解すれば、同様のテーブルに対して確実に動作する拡張機能を作成できます。