const fs = require('fs');
const path = require('path');
const { JSDOM } = require('jsdom');
const { stringify } = require('csv-stringify/sync');
function parseArgs(argv) {
const args = {
input: './calendarData.html',
format: 'json',
out: ''
};
for (let i = 0; i < argv.length; i += 1) {
const a = argv[i];
if (a === '--in' && argv[i + 1]) {
args.input = String(argv[i + 1]);
i += 1;
continue;
}
if (a === '--csv') {
args.format = 'csv';
continue;
}
if (a === '--html') {
args.format = 'html';
continue;
}
if (a === '--json') {
args.format = 'json';
continue;
}
if (a === '--out' && argv[i + 1]) {
args.out = String(argv[i + 1]);
i += 1;
continue;
}
if (a === '--help' || a === '-h') {
args.help = true;
}
}
return args;
}
function printHelp() {
console.log('設備予約HTML(calendarData.html)を解析して JSON/CSV を出力します');
console.log('');
console.log('使い方:');
console.log(' node analyzeCalendar.js --json # JSON出力 (default)');
console.log(' node analyzeCalendar.js --csv # CSV出力 (設備名,開始時間,終了時間,内容,登録者)');
console.log(' node analyzeCalendar.js --html # HTML出力 (table)');
console.log(' node analyzeCalendar.js --csv --out out.csv # CSVをファイル保存');
console.log(' node analyzeCalendar.js --html --out out.html # HTMLをファイル保存');
console.log(' node analyzeCalendar.js --in calendarData.html # 入力HTML指定');
}
function parseTimeRange(text) {
const normalized = String(text || '').trim();
const m = normalized.match(/(\d{1,2}:\d{2})\s*[~〜]\s*(\d{1,2}:\d{2})/);
if (!m) {
return { start: '', end: '' };
}
return { start: m[1], end: m[2] };
}
function toCsv(result) {
const records = [];
records.push(['設備名', '開始時間', '終了時間', '内容', '登録者']);
for (const facilityName of Object.keys(result)) {
const items = Array.isArray(result[facilityName]) ? result[facilityName] : [];
for (const item of items) {
const timeRange = item?.[0] || '';
const content = item?.[1] || '';
const registrant = item?.[2] || '';
const { start, end } = parseTimeRange(timeRange);
records.push([facilityName, start, end, content, registrant]);
}
}
return stringify(records, { header: false });
}
function escapeHtml(text) {
return String(text ?? '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function timeToMinutes(hhmm) {
const m = String(hhmm || '').trim().match(/^(\d{1,2}):(\d{2})$/);
if (!m) return NaN;
const h = Number(m[1]);
const mm = Number(m[2]);
if (h < 0 || h > 23 || mm < 0 || mm > 59) return NaN;
return h * 60 + mm;
}
function buildTimeSlots(startHHMM, endHHMM, stepMinutes) {
const start = timeToMinutes(startHHMM);
const end = timeToMinutes(endHHMM);
if (!Number.isFinite(start) || !Number.isFinite(end) || start >= end) {
throw new Error(`時間枠の指定が不正です: ${startHHMM} - ${endHHMM}`);
}
const slots = [];
for (let t = start; t < end; t += stepMinutes) {
const h = Math.floor(t / 60);
const m = t % 60;
slots.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`);
}
return { startMinutes: start, endMinutes: end, slots };
}
function toScheduleTableHtml(facilities, result, options) {
const startHHMM = options?.startHHMM || '07:00';
const endHHMM = options?.endHHMM || '21:00';
const stepMinutes = options?.stepMinutes || 30;
const { startMinutes, endMinutes, slots } = buildTimeSlots(startHHMM, endHHMM, stepMinutes);
const slotCount = slots.length;
const facilityList = Array.isArray(facilities) ? facilities : [];
const rowsHtml = [];
for (const facilityName of facilityList) {
const items = Array.isArray(result[facilityName]) ? result[facilityName] : [];
// startIndex -> { span, html }
const cells = new Map();
for (const item of items) {
const timeRange = item?.[0] || '';
const content = item?.[1] || '';
const registrant = item?.[2] || '';
const { start, end } = parseTimeRange(timeRange);
const sMin = timeToMinutes(start);
const eMin = timeToMinutes(end);
if (!Number.isFinite(sMin) || !Number.isFinite(eMin) || sMin >= eMin) {
continue;
}
const clippedStart = Math.max(sMin, startMinutes);
const clippedEnd = Math.min(eMin, endMinutes);
if (clippedStart >= clippedEnd) {
continue;
}
const startIndex = Math.floor((clippedStart - startMinutes) / stepMinutes);
const endIndex = Math.ceil((clippedEnd - startMinutes) / stepMinutes);
const span = Math.max(1, Math.min(slotCount, endIndex) - Math.max(0, startIndex));
const key = Math.max(0, startIndex);
if (cells.has(key)) {
// 予定が重複している場合は後勝ちにせず、先勝ちで維持
continue;
}
const cellHtml = `${escapeHtml(content)}
${escapeHtml(registrant)}`;
cells.set(key, { span, html: cellHtml });
}
const tds = [];
tds.push(`
時間枠: ${escapeHtml(startHHMM)}〜${escapeHtml(endHHMM)}(${stepMinutes}分刻み)
${tableHtml} `; } function analyzeHtmlToResult(htmlText) { const dom = new JSDOM(htmlText); const document = dom.window.document; // 1. 設備名をすべて取得 const timeGridTitle = document.querySelector('.time_grid_title'); const facilityElements = timeGridTitle.querySelectorAll('.resource_cover a.name'); const facilities = Array.from(facilityElements).map(el => el.textContent.trim()).filter(Boolean); // 2. スケジュールデータを取得 const timeGridBody = document.querySelector('.time_grid_body'); const facilityGrids = Array.from(timeGridBody.querySelectorAll(':scope > .grid')); // 3. 設備ごとに配列に格納 const result = {}; facilities.forEach((facility) => { result[facility] = []; }); function splitScheduleText(text) { // 例: "10:00~11:30, 打合せ, 伊藤理奈" // 件名にカンマが入っても壊れにくいように、最後を登録者として扱う const parts = String(text || '').split(/\s*,\s*/).filter(p => p.length > 0); if (parts.length === 0) { return { time: '', title: '', registrant: '' }; } if (parts.length === 1) { return { time: parts[0], title: '', registrant: '' }; } if (parts.length === 2) { return { time: parts[0], title: parts[1], registrant: '' }; } const time = parts[0]; const registrant = parts[parts.length - 1]; const title = parts.slice(1, parts.length - 1).join(', '); return { time, title, registrant }; } // データを設備ごとに抽出 const count = Math.min(facilities.length, facilityGrids.length); for (let facilityIndex = 0; facilityIndex < count; facilityIndex += 1) { const facilityName = facilities[facilityIndex]; const grid = facilityGrids[facilityIndex]; const disabledMsgs = Array.from(grid.querySelectorAll('.schedule.disabled .msg')); let previousText = ''; for (const msg of disabledMsgs) { const text = (msg.textContent || '').trim(); if (!text) { previousText = ''; continue; } // 同一予定が複数枠(30分単位など)にまたがる場合、同じ文言が連続するので除外 if (text === previousText) { continue; } previousText = text; const { time, title, registrant } = splitScheduleText(text); result[facilityName].push([time || '', title || '', registrant || '']); } } return { facilities, result }; } function main() { const args = parseArgs(process.argv.slice(2)); if (args.help) { printHelp(); return; } const inputPath = path.resolve(args.input); const html = fs.readFileSync(inputPath, 'utf-8'); const analyzed = analyzeHtmlToResult(html); const facilities = analyzed.facilities; const result = analyzed.result; if (args.format === 'csv') { const csv = toCsv(result); if (args.out) { const outPath = path.resolve(args.out); fs.writeFileSync(outPath, csv, 'utf-8'); } else { process.stdout.write(csv); } } else if (args.format === 'html') { const outPath = path.resolve(args.out || './schedule_table.html'); const htmlOut = toScheduleTableHtml(facilities, result, { title: '設備予約' }); fs.writeFileSync(outPath, htmlOut, 'utf-8'); console.log(`saved: ${outPath}`); } else { console.log(JSON.stringify(result, null, 2)); } module.exports = result; } main();