開発完了
Analyst Estimates (Actuals and Consensus) Chrome拡張機能実装計画
概要
KoyfinのAnalyst Estimatesページ(/estimates/eac/)からActuals and Consensusデータを一括取得するChrome拡張機能の実装計画。
対象URL: https://app.koyfin.com/estimates/eac/eq-{ID}
既存実装の概要
プロジェクト構造
C:\Users\numbe\Git_repo\chrome-extension-kofyin\
├── manifest.json # 拡張機能設定 (v1.3.0)
├── background.js # ダウンロードAPI
├── content.js # メインロジック(TableCopier, FATableCopier クラス)
├── popup.html / popup.js # Popup UI
├── content.css # スタイル
└── scraper/
├── tsv_to_sqlite.py # TSV → SQLite インポーター
├── import_tsv_dir.py # バッチインポーター
├── schema.sql # DBスキーマ
└── data/ # データディレクトリ
既存クラス
1. TableCopier クラス (content.js:3-481)
用途: 一般的なKoyfinテーブル(.table-styles__table___fNvz7)
主要メソッド:
copyTableDataWithScroll(table, button)- スクロールしながらデータ収集extractTableData(table)- テーブルデータ抽出extractRowData(row)- 行データ抽出(位置順にソート)cleanMetricName(name)- メトリクス名正規化
特徴:
- 仮想スクロール対応(300ms間隔でスクロール)
- グループヘッダー・データ行の識別
- 親子メトリクス関係の追跡(
-Chgサフィックス)
2. FATableCopier クラス (content.js:484-1500+)
用途: Financial Analysisページ(.fa-table__root___cf3J4)
主要メソッド:
copyAllTablesData(button)- Annual + Quarterly 全タブデータ収集collectTabsDataForPeriod(tabs, button, periodLabel, ...)- 期間別データ収集switchPeriod(periodType)- Annual/Quarterly 切り替えgetCompanyInfo()- 企業情報取得startBatchDownload(tickers)- 一括ダウンロード
ページナビゲーション:
openSearchAndNavigate(ticker)- 検索バーで銘柄遷移waitForFAPage()- FAページ読み込み待機
新規実装: EACTableCopier クラス
実装方針
Analyst Estimatesページ(/estimates/eac/)は既存のTableCopierクラスのテーブル構造(.table-styles__table___fNvz7)を使用している可能性が高い。
FATableCopierと同様のパターンで、Annual/Quarterly切り替えと全データ収集を実装する。
content.js への追加コード
// Analyst Estimates (Actuals and Consensus) 用のテーブルコピー機能
class EACTableCopier {
constructor() {
this.init();
}
init() {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => this.addCopyButtons());
} else {
this.addCopyButtons();
}
const observer = new MutationObserver(() => this.addCopyButtons());
observer.observe(document.body, { childList: true, subtree: true });
}
// ページがEACページかどうかを判定
isEACPage() {
return window.location.href.includes('/estimates/eac/');
}
addCopyButtons() {
if (!this.isEACPage()) return;
// EAC用のテーブルを検索(既存TableCopierと同じセレクタの可能性あり)
// 実際のHTML構造を確認して調整
const tables = document.querySelectorAll('.table-styles__table___fNvz7');
tables.forEach(table => {
if (table.dataset.eacCopyButtonAdded) return;
table.dataset.eacCopyButtonAdded = 'true';
const container = this.createCopyButtonContainer(table);
const parentBox = table.closest('.box__box___QniKz');
if (parentBox) {
parentBox.insertBefore(container, parentBox.firstChild);
}
});
}
createCopyButtonContainer(table) {
const container = document.createElement('div');
container.className = 'eac-table-copier-container';
container.style.cssText = 'position: absolute; top: 5px; right: 10px; z-index: 100; display: flex; gap: 8px;';
// 全データコピーボタン
const allButton = document.createElement('button');
allButton.className = 'table-copier-button eac-table-copier-all';
allButton.textContent = 'EAC全データをコピー';
allButton.style.cssText = 'background-color: #2196F3;';
allButton.addEventListener('click', () => this.copyAllEACData(allButton));
container.appendChild(allButton);
return container;
}
// 企業情報取得(FATableCopierと同じロジックを再利用可能)
getCompanyInfo() {
const companyNameEl = document.querySelector('.quote-box__securityName___XtQtz');
const companyName = companyNameEl ? companyNameEl.textContent.trim() : '';
const tickerEl = document.querySelector('.market-quote-base__ticker___XVoJn');
const ticker = tickerEl ? tickerEl.textContent.trim() : '';
const exchangeEl = document.querySelector('.country-display-flag__koy__textEllipsis___peCBf');
const exchange = exchangeEl ? exchangeEl.textContent.trim() : '';
return { companyName, ticker, exchange };
}
// Annual/Quarterly 期間切り替え
// セレクタは実際のページで確認が必要
async switchPeriod(periodType) {
// 期間切り替えボタンのセレクタ(要確認)
// スクリーンショットによると "Annual (Y)" と "Quarterly (Q)" ボタンがある
const periodButtons = document.querySelectorAll('button, [role="button"]');
for (const btn of periodButtons) {
const text = btn.textContent.trim().toLowerCase();
if (periodType === 'annual' && (text.includes('annual') || text === 'y')) {
btn.click();
await new Promise(resolve => setTimeout(resolve, 500));
return true;
}
if (periodType === 'quarterly' && (text.includes('quarterly') || text === 'q')) {
btn.click();
await new Promise(resolve => setTimeout(resolve, 500));
return true;
}
}
return false;
}
// メインのデータ収集関数
async copyAllEACData(button) {
const originalText = button.textContent;
button.disabled = true;
try {
const companyInfo = this.getCompanyInfo();
const finalData = [];
// 企業情報ヘッダー
finalData.push(['企業名', companyInfo.companyName]);
finalData.push(['ティッカー', companyInfo.ticker]);
finalData.push(['取引所', companyInfo.exchange]);
finalData.push(['取得日時', new Date().toISOString()]);
finalData.push([]);
// === Annual データ収集 ===
button.textContent = 'Annual に切り替え中...';
await this.switchPeriod('annual');
await new Promise(resolve => setTimeout(resolve, 1000));
button.textContent = 'Annual データを収集中...';
const annualData = await this.collectTableDataWithScroll();
if (annualData.length > 0) {
finalData.push(['========== Annual ==========']);
finalData.push([]);
annualData.forEach(row => finalData.push(row));
finalData.push([]);
}
// === Quarterly データ収集 ===
button.textContent = 'Quarterly に切り替え中...';
await this.switchPeriod('quarterly');
await new Promise(resolve => setTimeout(resolve, 1000));
button.textContent = 'Quarterly データを収集中...';
const quarterlyData = await this.collectTableDataWithScroll();
if (quarterlyData.length > 0) {
finalData.push(['========== Quarterly ==========']);
finalData.push([]);
quarterlyData.forEach(row => finalData.push(row));
}
// TSV形式に変換
const tsv = finalData.map(row => row.join('\t')).join('\n');
// クリップボードにコピー
await navigator.clipboard.writeText(tsv);
button.textContent = originalText;
button.disabled = false;
this.showNotification(`EACデータをコピーしました!`);
} catch (error) {
console.error('EACデータの抽出に失敗:', error);
button.textContent = originalText;
button.disabled = false;
this.showNotification('エラーが発生しました', true);
}
}
// 既存TableCopierのcopyTableDataWithScrollを参考にした実装
async collectTableDataWithScroll() {
// テーブルとスクロールコンテナを取得(セレクタ要確認)
const table = document.querySelector('.table-styles__table___fNvz7');
if (!table) return [];
const scrollContainer = table.querySelector('.table-styles__table__scrollContainer___WBAWY');
if (!scrollContainer) {
// スクロールなしの場合は直接抽出
return this.extractTableData(table);
}
// スクロールしながらデータ収集(TableCopierのロジックを流用)
const allItemsMap = new Map();
// ... 以下、TableCopier.copyTableDataWithScrollの実装を参考に
return this.extractTableData(table);
}
extractTableData(table) {
// 実際のHTML構造を確認して実装
// TableCopier.extractTableDataを参考に
const data = [];
// ...
return data;
}
showNotification(message, isError = false) {
const notification = document.createElement('div');
notification.className = `table-copier-notification ${isError ? 'error' : 'success'}`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.classList.add('fade-out');
setTimeout(() => notification.remove(), 300);
}, 3000);
}
}
// 初期化(content.js末尾に追加)
const eacTableCopier = new EACTableCopier();
一括ダウンロード対応
FATableCopierのstartBatchDownloadを参考に、EAC用のバッチダウンロード機能を実装。
ナビゲーションの変更点:
- FAページ: 検索 → Ticker選択 → "Highlights" クリック
- EACページ: 検索 → Ticker選択 → "Actuals and Consensus" クリック
// EACページへのナビゲーション
EACTableCopier.prototype.openSearchAndNavigateToEAC = async function(ticker) {
// 検索処理(FATableCopier.openSearchAndNavigateと同じ)
// ...
// "Actuals and Consensus" をクリック(Highlightsの代わり)
const menuItems = document.querySelectorAll('[class*="menu"] [class*="item"], [role="menuitem"]');
for (const item of menuItems) {
const text = item.textContent || '';
if (text.includes('Actuals and Consensus') || text.includes('EAC')) {
item.click();
break;
}
}
};
ダウンロード構造
ディレクトリ構成
Downloads/
└── actual-and-consensus/ # 新規ディレクトリ(background.jsで設定)
└── 2025-12-14/ # 日付ディレクトリ
├── NVDA_2025-12-14.tsv
├── AAPL_2025-12-14.tsv
└── ...
background.js の修正
// ダウンロードディレクトリをデータタイプに応じて変更
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === 'downloadTSV') {
const { content, filename, dataType } = message;
// データタイプに応じたサブディレクトリ
const subdir = dataType === 'eac' ? 'actual-and-consensus' : 'koyfin-data';
const today = new Date().toISOString().split('T')[0];
const blob = new Blob([content], { type: 'text/tab-separated-values' });
const url = URL.createObjectURL(blob);
chrome.downloads.download({
url: url,
filename: `${subdir}/${today}/${filename}`,
saveAs: false
});
}
});
SQLiteスキーマ拡張
scraper/schema.sql への追加
-- Actuals and Consensus Annual データ
CREATE TABLE IF NOT EXISTS eac_annual (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER NOT NULL,
period_id INTEGER NOT NULL,
section TEXT NOT NULL, -- 'income_statement', 'gross_profit', etc.
metric_name TEXT NOT NULL,
metric_value TEXT,
yoy_change TEXT, -- YoY % Chg
qoq_change TEXT, -- QoQ % Chg
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (company_id) REFERENCES companies(id),
FOREIGN KEY (period_id) REFERENCES financial_periods(id),
UNIQUE (company_id, period_id, section, metric_name)
);
-- Actuals and Consensus Quarterly データ
CREATE TABLE IF NOT EXISTS eac_quarterly (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER NOT NULL,
period_id INTEGER NOT NULL,
section TEXT NOT NULL,
metric_name TEXT NOT NULL,
metric_value TEXT,
yoy_change TEXT,
qoq_change TEXT,
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (company_id) REFERENCES companies(id),
FOREIGN KEY (period_id) REFERENCES financial_periods(id),
UNIQUE (company_id, period_id, section, metric_name)
);
CREATE INDEX IF NOT EXISTS idx_eac_annual_company ON eac_annual(company_id);
CREATE INDEX IF NOT EXISTS idx_eac_annual_period ON eac_annual(period_id);
CREATE INDEX IF NOT EXISTS idx_eac_quarterly_company ON eac_quarterly(company_id);
CREATE INDEX IF NOT EXISTS idx_eac_quarterly_period ON eac_quarterly(period_id);
-- ビュー
CREATE VIEW IF NOT EXISTS v_eac_annual AS
SELECT
c.ticker,
c.name AS company_name,
p.period_type,
p.period_label,
p.fiscal_year,
e.section,
e.metric_name,
e.metric_value,
e.yoy_change,
e.qoq_change
FROM eac_annual e
JOIN companies c ON e.company_id = c.id
JOIN financial_periods p ON e.period_id = p.id;
TSVパーサー (scraper/eac_tsv_to_sqlite.py)
#!/usr/bin/env python3
"""
Actuals and Consensus TSV → SQLite インポーター
"""
import sqlite3
import re
from pathlib import Path
from datetime import datetime
# EAC固有のセクションマッピング
EAC_SECTION_MAP = {
"Income Statement": "income_statement",
# 他のセクション名は実際のTSVから確認して追加
}
def parse_eac_tsv(filepath):
"""EAC TSVファイルをパース"""
with open(filepath, 'r', encoding='utf-8') as f:
lines = f.readlines()
result = {
'header': {},
'annual': [],
'quarterly': []
}
current_period = None # 'annual' or 'quarterly'
current_section = None
headers = []
for line in lines:
line = line.strip()
if not line:
continue
# ヘッダー情報
if line.startswith('企業名\t'):
result['header']['company_name'] = line.split('\t')[1]
elif line.startswith('ティッカー\t'):
result['header']['ticker'] = line.split('\t')[1]
elif line.startswith('取引所\t'):
result['header']['exchange'] = line.split('\t')[1]
# 期間セクション
elif '========== Annual ==========' in line:
current_period = 'annual'
elif '========== Quarterly ==========' in line:
current_period = 'quarterly'
# セクションヘッダー
elif line.startswith('=== ') and line.endswith(' ==='):
current_section = line.replace('=== ', '').replace(' ===', '')
headers = []
# データ行
elif current_period and '\t' in line:
cols = line.split('\t')
if not headers:
headers = cols # ヘッダー行
else:
# データ行
row = {
'section': current_section,
'metric_name': cols[0] if cols else '',
'values': dict(zip(headers[1:], cols[1:]))
}
if current_period == 'annual':
result['annual'].append(row)
else:
result['quarterly'].append(row)
return result
def import_eac_data(db_path, tsv_dir):
"""ディレクトリ内のTSVをSQLiteにインポート"""
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
tsv_files = Path(tsv_dir).glob('*.tsv')
for tsv_file in tsv_files:
print(f'Processing: {tsv_file}')
data = parse_eac_tsv(tsv_file)
# ... インポート処理
conn.commit()
conn.close()
if __name__ == '__main__':
import sys
db_path = sys.argv[1] if len(sys.argv) > 1 else 'data/koyfin.db'
tsv_dir = sys.argv[2] if len(sys.argv) > 2 else 'data/actual-and-consensus'
import_eac_data(db_path, tsv_dir)
TypeScript Composable生成
apps/web/scripts/generate-actual-consensus-data.mjs
既存のgenerate-financial-data.mjsを参考に実装。
/**
* SQLite → TypeScript Composable 生成
* Actuals and Consensus用
*/
import Database from 'better-sqlite3';
import fs from 'fs';
import path from 'path';
const DB_PATH = 'C:/Users/numbe/Git_repo/chrome-extension-kofyin/scraper/data/koyfin.db';
const OUTPUT_PATH = './app/composables/actual-consensus-data.ts';
function generateActualConsensusData() {
const db = new Database(DB_PATH, { readonly: true });
// 企業リスト取得
const companies = db.prepare(`
SELECT DISTINCT c.ticker, c.name
FROM companies c
JOIN eac_annual e ON c.id = e.company_id
`).all();
const output = [];
output.push(`// Auto-generated from SQLite database`);
output.push(`// Generated at: ${new Date().toISOString()}`);
output.push('');
output.push(`export interface ActualConsensusData {`);
output.push(` ticker: string;`);
output.push(` name: string;`);
output.push(` annual: Record<string, any>;`);
output.push(` quarterly: Record<string, any>;`);
output.push(`}`);
output.push('');
output.push(`export const actualConsensusData: Record<string, ActualConsensusData> = {`);
for (const company of companies) {
// Annual データ
const annualData = db.prepare(`
SELECT * FROM v_eac_annual
WHERE ticker = ?
ORDER BY fiscal_year, period_label
`).all(company.ticker);
// Quarterly データ
const quarterlyData = db.prepare(`
SELECT * FROM v_eac_quarterly
WHERE ticker = ?
ORDER BY fiscal_year, period_label
`).all(company.ticker);
// TypeScript出力
output.push(` "${company.ticker}": {`);
output.push(` ticker: "${company.ticker}",`);
output.push(` name: "${company.name}",`);
output.push(` annual: ${JSON.stringify(annualData, null, 6)},`);
output.push(` quarterly: ${JSON.stringify(quarterlyData, null, 6)},`);
output.push(` },`);
}
output.push(`};`);
fs.writeFileSync(OUTPUT_PATH, output.join('\n'));
console.log(`Generated: ${OUTPUT_PATH}`);
db.close();
}
generateActualConsensusData();
実装順序
Phase 1: HTML構造調査(最優先)
- Koyfinの
/estimates/eac/ページをブラウザで開く - DevToolsでテーブル構造を確認:
- テーブルのクラス名(
.table-styles__table___fNvz7か別か) - スクロールコンテナのクラス名
- 期間切り替えボタン(Annual/Quarterly)のセレクタ
- 行・セルのクラス名
- テーブルのクラス名(
- 「テーブルをコピー(全体)」ボタンが既存で機能するか確認
Phase 2: EACTableCopier 実装
content.jsにEACTableCopierクラスを追加- ページ検出ロジック実装(
/estimates/eac/判定) - 期間切り替え(Annual/Quarterly)実装
- データ収集ロジック実装
- テスト(単一銘柄)
Phase 3: 一括ダウンロード
background.jsにダウンロードディレクトリ設定追加popup.htmlにデータタイプ選択UI追加EACTableCopier.startBatchDownload実装- テスト(複数銘柄)
Phase 4: SQLite統合
schema.sqlにEACテーブル追加eac_tsv_to_sqlite.py実装- インポートテスト
Phase 5: TypeScript生成
generate-actual-consensus-data.mjs実装- 型定義追加
- 既存コンポーネントとの統合確認
注意事項
スクロール処理
- EACテーブルは横方向にスクロールする可能性あり(期間が多い)
- 既存の「テーブルをコピー(全体)」ボタンがあるなら、その内部ロジックを活用
期間フォーマット
EACページ固有のフォーマット:
- Fiscal Quarters:
4Q FY2016A,1Q FY2017Aなど(A=Actual) - Period Ending:
Jan-31-2016など - Report Date:
Feb-17-2016など
既存パイプラインとの整合性
- 同じ
koyfin.dbを使用 companiesテーブルは共有financial_periodsは新規レコード追加が必要になる可能性あり
参考ファイル
| ファイル | 用途 |
|---|---|
chrome-extension-kofyin/content.js | 既存実装(TableCopier, FATableCopier) |
chrome-extension-kofyin/background.js | ダウンロードAPI |
chrome-extension-kofyin/scraper/tsv_to_sqlite.py | TSVパーサー参考 |
chrome-extension-kofyin/scraper/schema.sql | DBスキーマ参考 |
apps/web/scripts/generate-financial-data.mjs | TS生成スクリプト参考 |