322 lines
11 KiB
JavaScript
322 lines
11 KiB
JavaScript
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, '"')
|
||
.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)}<br/>${escapeHtml(registrant)}`;
|
||
cells.set(key, { span, html: cellHtml });
|
||
}
|
||
|
||
const tds = [];
|
||
tds.push(`<th class="facility">${escapeHtml(facilityName)}</th>`);
|
||
|
||
for (let i = 0; i < slotCount; i += 1) {
|
||
const cell = cells.get(i);
|
||
if (cell) {
|
||
tds.push(`<td class="booked" colspan="${cell.span}">${cell.html}</td>`);
|
||
i += (cell.span - 1);
|
||
continue;
|
||
}
|
||
tds.push('<td class="empty"></td>');
|
||
}
|
||
|
||
rowsHtml.push(`<tr>${tds.join('')}</tr>`);
|
||
}
|
||
|
||
const headerCells = ['<th class="facility">設備名</th>', ...slots.map(s => `<th class="time">${escapeHtml(s)}</th>`)].join('');
|
||
const tableHtml = `
|
||
<table class="schedule">
|
||
<thead>
|
||
<tr>${headerCells}</tr>
|
||
</thead>
|
||
<tbody>
|
||
${rowsHtml.join('\n ')}
|
||
</tbody>
|
||
</table>`;
|
||
|
||
const title = options?.title || '設備予約';
|
||
const style = `
|
||
<style>
|
||
body { font-family: system-ui, -apple-system, Segoe UI, sans-serif; margin: 16px; }
|
||
h1 { font-size: 18px; margin: 0 0 12px; }
|
||
.note { font-size: 12px; color: #444; margin: 0 0 12px; }
|
||
table.schedule { border-collapse: collapse; width: 100%; table-layout: fixed; }
|
||
table.schedule th, table.schedule td { border: 1px solid #ccc; padding: 4px; vertical-align: top; }
|
||
table.schedule th.facility { position: sticky; left: 0; background: #f7f7f7; width: 220px; min-width: 220px; max-width: 220px; }
|
||
table.schedule th.time { font-size: 11px; background: #fafafa; }
|
||
table.schedule td.empty { background: #fff; }
|
||
table.schedule td.booked { background: #e8f0ff; font-size: 12px; white-space: normal; word-break: break-word; }
|
||
</style>`;
|
||
|
||
return `<!doctype html>
|
||
<html lang="ja">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>${escapeHtml(title)}</title>${style}
|
||
</head>
|
||
<body>
|
||
<h1>${escapeHtml(title)}</h1>
|
||
<p class="note">時間枠: ${escapeHtml(startHHMM)}〜${escapeHtml(endHHMM)}(${stepMinutes}分刻み)</p>
|
||
${tableHtml}
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
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(); |