From 9f2147a8d34015c841668cbbb34427ae969d0b52 Mon Sep 17 00:00:00 2001 From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:01:49 +0900 Subject: hp-archive: 図書館のイベント掲載と絞り込みを追加 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 小平市立図書館の月間カレンダーから、おはなし会・絵本のへやなど16件を掲載 - 「図書館」カテゴリボタンを追加し、カレンダー下部に絞り込み中の表示を追加 - 11月5日の公共施設マネジメント調査特別委員会を視察(傍聴なし)に修正 - 図書館イベントの日付更新スクリプトと、X投稿用のキャプチャスクリプトを追加 --- scripts/capture-schedule-shot.mjs | 161 +++++++++++++++++++++++++++++++++++++ scripts/update-library-dates.mjs | 165 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 326 insertions(+) create mode 100644 scripts/capture-schedule-shot.mjs create mode 100644 scripts/update-library-dates.mjs (limited to 'scripts') diff --git a/scripts/capture-schedule-shot.mjs b/scripts/capture-schedule-shot.mjs new file mode 100644 index 0000000..7ebdee5 --- /dev/null +++ b/scripts/capture-schedule-shot.mjs @@ -0,0 +1,161 @@ +#!/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(); diff --git a/scripts/update-library-dates.mjs b/scripts/update-library-dates.mjs new file mode 100644 index 0000000..2317b17 --- /dev/null +++ b/scripts/update-library-dates.mjs @@ -0,0 +1,165 @@ +#!/usr/bin/env node +// 小平市立図書館のイベントカレンダーから開催日を取得し、 +// 行事予定ページの図書館イベント行(日付セル)を更新する。 +// +// 使い方: +// node scripts/update-library-dates.mjs … 変更内容を表示(書き換えない) +// node scripts/update-library-dates.mjs --apply … 実際に書き換える +// node scripts/update-library-dates.mjs --months 4 … 当月から4か月分を取得(既定3) +// +// 行の追加・削除は自動では行わない(内容を確認して手で編集する)。 +// 表にない新しいイベント、カレンダーから消えたイベントは一覧で表示する。 + +import fs from 'node:fs'; +import path from 'node:path'; + +const BASE = 'https://library.kodaira.ed.jp/viewer/'; +const UA = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; + +const args = process.argv.slice(2); +const apply = args.includes('--apply'); +const monthsArg = args.indexOf('--months'); +const months = monthsArg >= 0 ? Number(args[monthsArg + 1]) || 3 : 3; +const targetIdx = args.indexOf('--target'); +const targetPath = + targetIdx >= 0 + ? args[targetIdx + 1] + : path.join('src', 'content', 'docs', 'kodaira-data', 'hp-archive', 'schedule.mdx'); + +async function fetchText(url) { + const res = await fetch(url, { headers: { 'User-Agent': UA } }); + if (!res.ok) throw new Error(`${res.status} ${url}`); + return res.text(); +} + +function normalize(html) { + return html.replace(/\s+/g, ' '); +} + +// カレンダーから { id: { title, lib, dates:Set } } を作る +async function collectEvents() { + const now = new Date(); + const events = new Map(); + + for (let i = 0; i < months; i += 1) { + const d = new Date(now.getFullYear(), now.getMonth() + i, 1); + const url = `${BASE}calendar-monthly.html?date=${d.getFullYear()}/${d.getMonth() + 1}/1`; + const html = normalize(await fetchText(url)); + const rows = html.match(/