#!/usr/bin/env node // 行事予定ページの「カテゴリで絞り込む」から「会場の地図」までを1枚の画像にする。 // X(旧Twitter)への投稿用。ビルド済みの dist/ をローカルで配信して撮影する。 // // 使い方: // node scripts/capture-schedule-shot.mjs // node scripts/capture-schedule-shot.mjs --category 図書館 --width 900 --out drafts/schedule-card.png // node scripts/capture-schedule-shot.mjs --zoom 16 … 地図の拡大(ピンが小さいとき) import http from 'node:http'; import fs from 'node:fs'; import path from 'node:path'; import { chromium } from 'playwright'; const args = process.argv.slice(2); const opt = (name, fallback) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : fallback; }; const category = opt('category', '図書館'); // 1120px 付近だとサイドバーが出ず、本文が最も広くなる(X で見やすい縦横比になる) const width = Number(opt('width', 1120)); // 本文の最大幅。広げると縦長になりすぎない const contentWidth = opt('contentWidth', '56rem'); // カレンダーで選ぶ日付(YYYY-MM-DD)。未指定なら今日 const date = opt('date', ''); // 地図でクリックしてフキダシを開くピン(会場名の一部)。未指定ならクリックしない const pin = opt('pin', ''); const out = opt('out', 'drafts/schedule-card.png'); const dpr = Number(opt('dpr', 2)); const root = path.resolve('dist'); if (!fs.existsSync(root)) { console.error('dist がありません。先に npm run build を実行してください。'); process.exit(1); } const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.webp': 'image/webp', '.jpg': 'image/jpeg', '.png': 'image/png', '.woff2': 'font/woff2', '.woff': 'font/woff', '.xml': 'application/xml', '.json': 'application/json', }; const server = http.createServer((req, res) => { const urlPath = decodeURIComponent(req.url.split('?')[0]); let file = path.join(root, urlPath); if (path.extname(file) === '') file = path.join(file, 'index.html'); fs.readFile(file, (err, data) => { if (err) { res.writeHead(404); res.end('not found'); return; } res.writeHead(200, { 'content-type': types[path.extname(file)] || 'application/octet-stream' }); res.end(data); }); }); await new Promise((r) => server.listen(0, r)); const port = server.address().port; const browser = await chromium.launch(); const page = await browser.newPage({ viewport: { width, height: 2600 }, deviceScaleFactor: dpr }); await page.goto(`http://localhost:${port}/kodaira-data/hp-archive/schedule/`, { waitUntil: 'networkidle', }); await page.waitForSelector('#schedule-map.leaflet-container'); // 本文を広めにして、縦長になりすぎないようにする(X での見え方を考慮) if (contentWidth) { await page.addStyleTag({ content: `:root { --sl-content-width: ${contentWidth} !important; }`, }); } // カテゴリを選ぶ(地図・カレンダーに反映される) if (category && category !== 'すべて') { await page.evaluate((label) => { const btn = [...document.querySelectorAll('.schedule-filter-btn')].find((b) => b.textContent.trim().replace(/^[^\s]+\s/, '').startsWith(label) ); if (btn) btn.click(); }, category); await page.waitForTimeout(400); } // 日付を選ぶ(見つからなければ月を送って探す) if (date) { let found = false; for (let i = 0; i < 15 && !found; i += 1) { found = await page.evaluate((key) => { const btn = document.querySelector(`.schedule-cal-day[data-date="${key}"]`); if (!btn) return false; btn.click(); return true; }, date); if (found) break; await page.evaluate(() => document.querySelector('.schedule-cal-nav[data-dir="1"]')?.click()); await page.waitForTimeout(200); } if (!found) throw new Error(`日付 ${date} のセルが見つかりませんでした`); await page.waitForTimeout(600); } // 地図のピンをクリックしてフキダシを開く if (pin) { const opened = await page.evaluate((name) => { const marker = [...document.querySelectorAll('.schedule-map-marker')].find((el) => (el.getAttribute('title') ?? '').includes(name) ); if (!marker) return false; marker.click(); return true; }, pin); if (!opened) throw new Error(`ピン「${pin}」が見つかりませんでした`); await page.waitForSelector('.leaflet-popup', { timeout: 5000 }); } // タイルの読み込みとピンの描画を待つ await page.waitForTimeout(2500); // 絞り込みの直前の説明文(または絞り込み欄)から、地図ブロックの下端までを切り抜く const clip = await page.evaluate(() => { const filters = document.getElementById('event-filters'); const mapBlock = document.querySelector('.schedule-map-block'); // 直前の段落(「カテゴリと日付を選ぶと…」)があれば上端に含める let start = filters; const prev = filters.previousElementSibling; if (prev && prev.tagName === 'P' && prev.textContent.trim().length > 0) start = prev; start.scrollIntoView({ block: 'start' }); window.scrollBy(0, -12); const s = start.getBoundingClientRect(); const m = mapBlock.getBoundingClientRect(); const left = Math.max(0, Math.min(s.left, m.left) - 8); const top = Math.max(0, s.top - 8); const right = Math.min(window.innerWidth, Math.max(s.right, m.right) + 8); return { x: Math.round(left), y: Math.round(top), width: Math.round(right - left), height: Math.round(m.bottom + 10 - top), }; }); fs.mkdirSync(path.dirname(out), { recursive: true }); await page.screenshot({ path: out, clip }); console.log(`保存: ${out}(${clip.width}x${clip.height} CSS px × ${dpr}倍 = ${clip.width * dpr}x${clip.height * dpr}px)`); await browser.close(); server.close();