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

概要
こんな自動化がChrome拡張機能でできるという事例です。
Chromeで表示した keiba.go.jpの出走表ページを指定したレース分をPDF作成します。
keiba.go.jpでも出走ページには公式でPDFがリンクしています。しかし、これにはオッズがありません。オッズが重要です。
別で開発する予想用アプリにこのPDFを取り込む時にも使いますからオッズは大事です。
このアプリでできること
- 地方競馬の出馬表ページ(keiba.go.jp)を A3縦向きPDF で一括生成
- 現在ブラウザで開いているレースページのURLから 開催日・開催場コードを自動取得
- 生成するレース番号の範囲を指定(例: 1R〜12R)
- 開催地名はUIで手入力し、ファイル名に使用
- macOS のネイティブフォルダ選択ダイアログで保存先を指定
- ファイル名は
YYYYMMDD開催地名NR.pdf(例:20260308高知1R.pdf)形式で自動生成
ユーザーに必要な技術
- Node.js の環境構築ができること(
npm install・node xxx.jsの実行) - Chrome拡張機能 のデベロッパーモードでのインストール方法を知っていること
- macOS 環境(フォルダ選択に
osascriptを使用)
作成手順
ファイル構成
keiba-pdf/
├── extension/ # Chrome拡張機能
│ ├── manifest.json
│ ├── popup.html
│ └── popup.js
└── server/ # ローカルPDF生成サーバー
├── server.js
└── package.json1. サーバーを作る
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-folder | macOS フォルダ選択ダイアログを表示し、パスを返す |
POST /generate-pdfs | Puppeteer でレースページを開き、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_raceDate と k_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.jsChrome 拡張機能のインストール:
- Chrome で
chrome://extensions/を開く - 右上の「デベロッパーモード」を ON にする
- 「パッケージ化されていない拡張機能を読み込む」をクリック
extension/フォルダを選択
4. 使い方
node server.jsを起動したままにする- keiba.go.jp のレースページ(出走表)を Chrome で開く
- 拡張機能アイコンをクリック
- 開催地名を入力
- PDF作成するレース範囲を指定
- 保存フォルダを参照して設定
- 「▶ 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',

