#!/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>/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> ([^<]*)<\/span>/g
) ?? [];
for (const item of items) {
const m = item.match(
/href="([^"]*)" class="cal_event_index_\d+">([^<]+)<\/a> ([^<]*)<\/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);
});