• #chrome-extension
  • #koyfin
  • #financial-data
  • #implementation-plan
開発完了

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構造調査(最優先)

  1. Koyfinの/estimates/eac/ページをブラウザで開く
  2. DevToolsでテーブル構造を確認:
    • テーブルのクラス名(.table-styles__table___fNvz7か別か)
    • スクロールコンテナのクラス名
    • 期間切り替えボタン(Annual/Quarterly)のセレクタ
    • 行・セルのクラス名
  3. 「テーブルをコピー(全体)」ボタンが既存で機能するか確認

Phase 2: EACTableCopier 実装

  1. content.jsEACTableCopierクラスを追加
  2. ページ検出ロジック実装(/estimates/eac/判定)
  3. 期間切り替え(Annual/Quarterly)実装
  4. データ収集ロジック実装
  5. テスト(単一銘柄)

Phase 3: 一括ダウンロード

  1. background.jsにダウンロードディレクトリ設定追加
  2. popup.htmlにデータタイプ選択UI追加
  3. EACTableCopier.startBatchDownload実装
  4. テスト(複数銘柄)

Phase 4: SQLite統合

  1. schema.sqlにEACテーブル追加
  2. eac_tsv_to_sqlite.py実装
  3. インポートテスト

Phase 5: TypeScript生成

  1. generate-actual-consensus-data.mjs実装
  2. 型定義追加
  3. 既存コンポーネントとの統合確認

注意事項

スクロール処理

  • 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.pyTSVパーサー参考
chrome-extension-kofyin/scraper/schema.sqlDBスキーマ参考
apps/web/scripts/generate-financial-data.mjsTS生成スクリプト参考