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(facilityName)}`); for (let i = 0; i < slotCount; i += 1) { const cell = cells.get(i); if (cell) { tds.push(`${cell.html}`); i += (cell.span - 1); continue; } tds.push(''); } rowsHtml.push(`${tds.join('')}`); } const headerCells = ['設備名', ...slots.map(s => `${escapeHtml(s)}`)].join(''); const tableHtml = ` ${headerCells} ${rowsHtml.join('\n ')}
`; const title = options?.title || '設備予約'; const style = ` `; return ` ${escapeHtml(title)}${style}

${escapeHtml(title)}

時間枠: ${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();