地方競馬の出馬表のPDF作成用にChrome拡張機能を作った事例

Chrome拡張 × Node.js で地方競馬の出馬表PDFを一括生成する

image

概要

こんな自動化がChrome拡張機能でできるという事例です。
Chromeで表示した keiba.go.jpの出走表ページを指定したレース分をPDF作成します。
keiba.go.jpでも出走ページには公式でPDFがリンクしています。しかし、これにはオッズがありません。オッズが重要です。

別で開発する予想用アプリにこのPDFを取り込む時にも使いますからオッズは大事です。

  • 地方競馬が好きなので中央競馬は非対応
  • keiba.go.jp のみ対応
  • keiba.go.jp 公式PDF との違いはWebページなのでオッズが入っている。
  • Windowsの動作未確認(macosで作成したため)

このアプリでできること

  • 地方競馬の出馬表ページ(keiba.go.jp)を A3縦向きPDF で一括生成
  • 現在ブラウザで開いているレースページのURLから 開催日・開催場コードを自動取得
  • 生成するレース番号の範囲を指定(例: 1R〜12R)
  • 開催地名はUIで手入力し、ファイル名に使用
  • macOS のネイティブフォルダ選択ダイアログで保存先を指定
  • ファイル名は YYYYMMDD開催地名NR.pdf(例: 20260308高知1R.pdf)形式で自動生成

ユーザーに必要な技術

  • Node.js の環境構築ができること(npm installnode xxx.js の実行)
  • Chrome拡張機能 のデベロッパーモードでのインストール方法を知っていること
  • macOS 環境(フォルダ選択に osascript を使用)

作成手順

ファイル構成

keiba-pdf/
├── extension/          # Chrome拡張機能
│   ├── manifest.json
│   ├── popup.html
│   └── popup.js
└── server/             # ローカルPDF生成サーバー
    ├── server.js
    └── package.json

1. サーバーを作る

server/package.json

{
  "name": "keiba-pdf-server",
  "version": "1.0.0",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "cors": "^2.8.5",
    "express": "^4.18.2",
    "puppeteer": "^21.0.0"
  }
}

server/server.js

Express サーバーに3つのエンドポイントを用意します。

エンドポイント役割
GET /health起動確認
GET /select-foldermacOS フォルダ選択ダイアログを表示し、パスを返す
POST /generate-pdfsPuppeteer でレースページを開き、A3 PDF を保存
const express = require('express');
const puppeteer = require('puppeteer');
const cors = require('cors');
const fs = require('fs');
const path = require('path');
const { exec } = require('child_process');

const app = express();
app.use(cors());
app.use(express.json());

app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

// macOS フォルダ選択ダイアログ
app.get('/select-folder', (req, res) => {
  const script = `osascript -e 'POSIX path of (choose folder with prompt "PDFの保存先フォルダを選択してください")'`;
  exec(script, (err, stdout) => {
    if (err) return res.status(400).json({ error: 'キャンセルされました' });
    res.json({ path: stdout.trim() });
  });
});

app.post('/generate-pdfs', async (req, res) => {
  const { date, babaCode, venueName, startRace, endRace, saveFolder } = req.body;

  if (!date || !babaCode || !venueName || !saveFolder) {
    return res.status(400).json({ error: '必須パラメータが不足しています' });
  }

  if (!fs.existsSync(saveFolder)) {
    fs.mkdirSync(saveFolder, { recursive: true });
  }

  const dateFormatted = date.replace(/-/g, '');
  const encodedDate   = date.replace(/-/g, '%2f');

  // Apple Silicon Mac の場合、システムの Chrome を指定することで動作が安定する
  const browser = await puppeteer.launch({
    executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
    headless: true,
    args: ['--no-sandbox', '--disable-setuid-sandbox'],
  });

  const results = [];

  try {
    for (let raceNo = startRace; raceNo <= endRace; raceNo++) {
      const url = `https://www.keiba.go.jp/KeibaWeb/TodayRaceInfo/DebaTable`
                + `?k_raceDate=${encodedDate}&k_raceNo=${raceNo}&k_babaCode=${babaCode}`;

      const page = await browser.newPage();
      await page.setViewport({ width: 1400, height: 1000 });

      try {
        const response = await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });
        const content  = await page.content();

        if (content.includes('該当するレースがありません') || response.status() === 404) {
          results.push({ raceNo, status: 'skipped', reason: 'レースなし' });
          await page.close();
          continue;
        }

        const fileName = `${dateFormatted}${venueName}${raceNo}R.pdf`;
        const filePath = path.join(saveFolder, fileName);

        await page.pdf({
          path: filePath,
          format: 'A3',
          landscape: false,       // 縦向き
          printBackground: true,
          margin: { top: '10mm', right: '10mm', bottom: '10mm', left: '10mm' },
        });

        results.push({ raceNo, fileName, status: 'success' });

      } catch (err) {
        results.push({ raceNo, status: 'error', error: err.message });
      } finally {
        await page.close();
      }
    }
  } finally {
    await browser.close();
  }

  res.json({ success: true, results });
});

app.listen(3000, () => {
  console.log('✅ PDF生成サーバー起動中 → http://localhost:3000');
});

2. Chrome 拡張機能を作る

extension/manifest.json

tabs パーミッションで現在のタブのURLを取得し、host_permissions でローカルサーバーへの通信を許可します。

{
  "manifest_version": 3,
  "name": "地方競馬PDF一括生成",
  "version": "1.1",
  "permissions": ["storage", "tabs"],
  "host_permissions": ["http://localhost:3000/*"],
  "action": {
    "default_popup": "popup.html"
  }
}

extension/popup.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <style>
    body { width: 300px; padding: 16px; font-family: -apple-system, sans-serif; font-size: 14px; }
    h2 { margin: 0 0 14px; font-size: 15px; }
    label { display: block; margin-top: 10px; font-size: 12px; color: #475569; font-weight: 600; }
    input { width: 100%; padding: 6px 8px; box-sizing: border-box; margin-top: 4px;
            border: 1px solid #cbd5e1; border-radius: 4px; font-size: 13px; }
    .info-box { background: #f1f5f9; border: 1px solid #cbd5e1; border-radius: 4px;
                padding: 8px 10px; margin-top: 4px; font-size: 12px; }
    .info-box span { font-weight: 600; color: #1e40af; }
    .row { display: flex; gap: 8px; }
    .row > div { flex: 1; }
    .folder-row { display: flex; gap: 6px; margin-top: 4px; }
    .folder-row input { flex: 1; margin-top: 0; background: #f8fafc; }
    button { width: 100%; margin-top: 16px; padding: 10px; background: #1d4ed8;
             color: white; border: none; border-radius: 6px; cursor: pointer;
             font-size: 14px; font-weight: 600; }
    button:disabled { background: #94a3b8; cursor: not-allowed; }
    .browse-btn { width: auto; margin-top: 0; padding: 6px 10px; background: #475569;
                  border-radius: 4px; font-size: 12px; white-space: nowrap; }
    #status { margin-top: 10px; padding: 8px; border-radius: 4px; display: none; font-size: 12px; }
    .success { background: #d1fae5; color: #065f46; }
    .error   { background: #fee2e2; color: #991b1b; }
    .loading { background: #dbeafe; color: #1e40af; }
    #progress { margin-top: 8px; font-size: 11px; color: #64748b; white-space: pre-line; }
  </style>
</head>
<body>
  <h2>🏇 競馬PDF一括生成</h2>

  <label>現在のページから取得</label>
  <div class="info-box" id="detectedInfo">読み取り中...</div>

  <label>開催地名(ファイル名に使用)</label>
  <input type="text" id="venueName" placeholder="例: 高知">

  <div class="row">
    <div><label>開始 R</label><input type="number" id="startRace" value="1" min="1" max="12"></div>
    <div><label>終了 R</label><input type="number" id="endRace" value="12" min="1" max="12"></div>
  </div>

  <label>保存フォルダ</label>
  <div class="folder-row">
    <input type="text" id="saveFolder" placeholder="参照ボタンで選択..." readonly>
    <button class="browse-btn" id="browseBtn">📁 参照</button>
  </div>

  <button id="generateBtn" disabled>▶ PDF一括生成</button>
  <div id="status"></div>
  <div id="progress"></div>

  <script src="popup.js"></script>
</body>
</html>

extension/popup.js

ページ読み込み時に現在のタブ URL を解析し、k_raceDatek_babaCode を自動取得します。

let detectedDate = null;
let detectedBabaCode = null;

document.addEventListener('DOMContentLoaded', async () => {
  chrome.storage.local.get(['saveFolder', 'venueName'], (data) => {
    if (data.saveFolder) document.getElementById('saveFolder').value = data.saveFolder;
    if (data.venueName)  document.getElementById('venueName').value  = data.venueName;
  });

  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  parseAndShowUrl(tab.url);
});

function parseAndShowUrl(urlStr) {
  const url = new URL(urlStr);
  if (!url.hostname.includes('keiba.go.jp') || !url.pathname.includes('DebaTable')) {
    showDetected('⚠️ 競馬のレースページを開いてから実行してください', false);
    return;
  }
  const rawDate  = url.searchParams.get('k_raceDate');
  const babaCode = url.searchParams.get('k_babaCode');

  detectedDate     = rawDate.replace(/\//g, '-');
  detectedBabaCode = babaCode;

  showDetected(`📅 <span>${rawDate}</span> / 開催場コード: <span>${babaCode}</span>`, true);
  document.getElementById('generateBtn').disabled = false;
}

// フォルダ選択(サーバー経由で osascript を呼び出す)
document.getElementById('browseBtn').addEventListener('click', async () => {
  const btn = document.getElementById('browseBtn');
  btn.disabled = true;
  try {
    const res  = await fetch('http://localhost:3000/select-folder');
    const data = await res.json();
    document.getElementById('saveFolder').value = data.path;
    chrome.storage.local.set({ saveFolder: data.path });
  } finally {
    btn.disabled = false;
  }
});

document.getElementById('generateBtn').addEventListener('click', async () => {
  const venueName  = document.getElementById('venueName').value.trim();
  const startRace  = parseInt(document.getElementById('startRace').value);
  const endRace    = parseInt(document.getElementById('endRace').value);
  const saveFolder = document.getElementById('saveFolder').value.trim();

  chrome.storage.local.set({ saveFolder, venueName });

  const btn = document.getElementById('generateBtn');
  btn.disabled = true;
  showStatus(`⏳ ${endRace - startRace + 1}件のPDF生成中...`, 'loading');

  try {
    const response = await fetch('http://localhost:3000/generate-pdfs', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ date: detectedDate, babaCode: detectedBabaCode,
                             venueName, startRace, endRace, saveFolder }),
    });
    const data = await response.json();

    const ok   = data.results.filter(r => r.status === 'success').length;
    const skip = data.results.filter(r => r.status === 'skipped').length;
    const err  = data.results.filter(r => r.status === 'error').length;
    showStatus(`✅ 完了: ${ok}件 / ⏭ スキップ: ${skip}件 / ❌ エラー: ${err}件`, 'success');

    document.getElementById('progress').textContent =
      data.results.map(r => r.status === 'success' ? `✓ ${r.fileName}` : `– ${r.raceNo}R`).join('\n');
  } catch {
    showStatus('❌ サーバー未起動 → node server.js を実行してください', 'error');
  } finally {
    btn.disabled = false;
  }
});

function showDetected(html, ok) {
  const el = document.getElementById('detectedInfo');
  el.innerHTML = html;
  el.style.background = ok ? '#eff6ff' : '#fef2f2';
}
function showStatus(msg, type) {
  const el = document.getElementById('status');
  el.textContent = msg; el.className = type; el.style.display = 'block';
}

3. セットアップ・起動手順

サーバーの初回セットアップ:

cd keiba-pdf/server
npm install
node server.js

Chrome 拡張機能のインストール:

  1. Chrome で chrome://extensions/ を開く
  2. 右上の「デベロッパーモード」を ON にする
  3. 「パッケージ化されていない拡張機能を読み込む」をクリック
  4. extension/ フォルダを選択

4. 使い方

  1. node server.js を起動したままにする
  2. keiba.go.jp のレースページ(出走表)を Chrome で開く
  3. 拡張機能アイコンをクリック
  4. 開催地名を入力
  5. PDF作成するレース範囲を指定
  6. 保存フォルダを参照して設定
  7. 「▶ PDF一括生成」をクリック

生成されたPDFは指定フォルダに YYYYMMDD<開催地名><レース回>.pdf 形式で保存されます。


Apple Silicon (M1/M2/M3) Mac での注意点

x64 版の Node.js が入っている場合、Puppeteer の内蔵 Chromium が Rosetta 経由になりタイムアウトします。
server.js でシステムの Chrome を直接指定することで回避できます。

executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',