aboutsummaryrefslogtreecommitdiffhomepage
path: root/scripts
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-11-02 09:01:49 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-11-02 09:01:49 +0900
commit9f2147a8d34015c841668cbbb34427ae969d0b52 (patch)
tree0f7f9c658ca14979678cdd6445751b9e7cad0f7a /scripts
parent5c6a79f7869b0da7a03be265686fc35ea110b0e0 (diff)
hp-archive: 図書館のイベント掲載と絞り込みを追加
- 小平市立図書館の月間カレンダーから、おはなし会・絵本のへやなど16件を掲載 - 「図書館」カテゴリボタンを追加し、カレンダー下部に絞り込み中の表示を追加 - 11月5日の公共施設マネジメント調査特別委員会を視察(傍聴なし)に修正 - 図書館イベントの日付更新スクリプトと、X投稿用のキャプチャスクリプトを追加
Diffstat (limited to 'scripts')
-rw-r--r--scripts/capture-schedule-shot.mjs161
-rw-r--r--scripts/update-library-dates.mjs165
2 files changed, 326 insertions, 0 deletions
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(/<tr class="calendar-daily cal_event">.*?<\/tr>/g) ?? [];
+
+ for (const row of rows) {
+ const dm = row.match(/calendar-daily\.html\?date=(\d{4})\/(\d{2})\/(\d{2})/);
+ if (!dm) continue;
+ const key = `${dm[1]}-${dm[2]}-${dm[3]}`;
+ const items =
+ row.match(
+ /<a target="_self" href="([^"]*)" class="cal_event_index_\d+">([^<]+)<\/a> <span class="icon[^"]*">([^<]*)<\/span>/g
+ ) ?? [];
+
+ for (const item of items) {
+ const m = item.match(
+ /href="([^"]*)" class="cal_event_index_\d+">([^<]+)<\/a> <span class="icon[^"]*">([^<]*)<\/span>/
+ );
+ if (!m) continue;
+ const id = (m[1].match(/id=(\d+)/) ?? [])[1];
+ if (!id) continue;
+ if (!events.has(id)) {
+ events.set(id, { id, title: m[2].trim(), lib: m[3].trim(), dates: new Set() });
+ }
+ events.get(id).dates.add(key);
+ }
+ }
+ }
+ return events;
+}
+
+// "2026-11-01" の集合を「11月1日・18日、12月6日・16日」の形にする
+// 日付が1つだけのときは「11月19日(木)」のように曜日を付ける
+function formatDates(dateKeys, withWeekday) {
+ const sorted = [...dateKeys].sort();
+ const [y0, m0, d0] = sorted[0].split('-').map(Number);
+ if (withWeekday && sorted.length === 1) {
+ const w = '日月火水木金土'[new Date(y0, m0 - 1, d0).getDay()];
+ return `${m0}月${d0}日(${w})`;
+ }
+ const byMonth = new Map();
+ for (const key of sorted) {
+ const [y, m, d] = key.split('-').map(Number);
+ const k = `${y}-${m}`;
+ if (!byMonth.has(k)) byMonth.set(k, []);
+ byMonth.get(k).push(d);
+ }
+ const parts = [];
+ for (const [k, days] of byMonth) {
+ const m = Number(k.split('-')[1]);
+ parts.push(`${m}月${days[0]}日${days.slice(1).map((d) => `・${d}日`).join('')}`);
+ }
+ return parts.join('、');
+}
+
+// 図書館のページ(info.html?id=…)を出典に含む行を対象にする。
+// 市報など他の出典と併記している行(例:「市報こだいら10月20日号3面/[図書館のページ](…)」)も拾う。
+const ROW_RE =
+ /^\| ([^|]*) \| ([^|]*) \| ([^|]*) \| ([^|]*) \| ([^|]*library\.kodaira\.ed\.jp\/viewer\/info\.html\?id=(\d+)[^|]*) \|$/;
+
+async function main() {
+ const events = await collectEvents();
+ const src = fs.readFileSync(targetPath, 'utf8');
+ const lines = src.split('\n');
+
+ const inTable = new Map();
+ const changes = [];
+
+ lines.forEach((line, index) => {
+ // Windows の CRLF でも一致するよう、照合時だけ行末の \r を除く
+ const m = line.replace(/\r$/, '').match(ROW_RE);
+ if (!m) return;
+ const [, datesCell, name, , , , id] = m;
+ inTable.set(id, { index, name, datesCell });
+
+ const ev = events.get(id);
+ if (!ev || ev.dates.size === 0) return;
+ const next = formatDates(ev.dates, ev.dates.size === 1);
+ if (next !== datesCell) changes.push({ index, name, id, from: datesCell, to: next });
+ });
+
+ console.log(`図書館の予定: カレンダー ${events.size}件 / 表 ${inTable.size}件(${months}か月分を取得)`);
+
+ if (changes.length === 0) {
+ console.log('日付の変更はありません。');
+ } else {
+ for (const c of changes) {
+ console.log(`- ${c.name}`);
+ console.log(` 現在: ${c.from}`);
+ console.log(` 新規: ${c.to}`);
+ if (apply) lines[c.index] = lines[c.index].replace(`| ${c.from} |`, `| ${c.to} |`);
+ }
+ }
+
+ const missing = [...events.values()].filter((e) => !inTable.has(e.id));
+ if (missing.length > 0) {
+ console.log('\n表にないイベント(内容を確認して追加してください):');
+ for (const e of missing) {
+ console.log(`- ${e.title}(${e.lib}) ${formatDates(e.dates)} ${BASE}info.html?id=${e.id}`);
+ }
+ }
+
+ const gone = [...inTable.entries()].filter(([id]) => !events.has(id));
+ if (gone.length > 0) {
+ console.log('\nカレンダーにない行(終了した可能性。必要なら削除してください):');
+ for (const [, row] of gone) console.log(`- ${row.name.replace(/^📚 /, '')}(${row.datesCell})`);
+ }
+
+ if (apply && changes.length > 0) {
+ fs.writeFileSync(targetPath, lines.join('\n'), 'utf8');
+ console.log(`\n${changes.length}件を ${targetPath} に反映しました。`);
+ } else if (!apply && changes.length > 0) {
+ console.log('\n(--apply を付けると反映します)');
+ }
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});