ken_nogi/NodeJS/アルファオフィススケジュール/analyzeCalendar.js
Kenichiro NOGI 88a402ce0f up
2026-07-10 18:13:30 +09:00

322 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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();