ken_nogi/NodeJS/LINEWORKS設備予約取得_/scripts/analyzeCalendar.js
Kenichiro NOGI 88a402ce0f up
2026-07-10 18:13:30 +09:00

623 lines
22 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 os = require('os');
const { Builder, Browser } = require('selenium-webdriver');
const chrome = require('selenium-webdriver/chrome');
const { JSDOM } = require('jsdom');
const { stringify } = require('csv-stringify/sync');
const SCRIPT_DIR = __dirname;
const ROOT_DIR = path.resolve(SCRIPT_DIR, '..');
function resolveScriptPath(targetPath) {
if (!targetPath) {
return SCRIPT_DIR;
}
if (path.isAbsolute(targetPath)) {
return targetPath;
}
return path.resolve(SCRIPT_DIR, targetPath);
}
function parseArgs(argv) {
const args = {
input: './calendarData.html',
format: 'json',
today: false,
out: '',
title: '設備予約',
location: ''
};
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 === '--today') {
args.today = true;
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 === '--title' && argv[i + 1]) {
args.title = String(argv[i + 1]);
i += 1;
continue;
}
if ((a === '--location' || a === '--site') && argv[i + 1]) {
args.location = 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 --today # 今日の予定を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指定 (scripts基準)');
console.log(' node analyzeCalendar.js --html --location 日野本社 # タイトルに設置先を付加');
console.log(' node analyzeCalendar.js --html --title 設備予約一覧 # タイトル文言を上書き');
}
function formatDateYYYYMMDDWithHyphen(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function buildNextDayCalendarUrl(isToday) {
const now = new Date();
let targetDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
if (isToday) {
targetDate = now;
}
const date = formatDateYYYYMMDDWithHyphen(targetDate);
return `https://calendar.worksmobile.com/web/resource/bookmark?date=${date}`;
}
async function fetchCalendarPageSource(outPath, isToday) {
const targetUrl = buildNextDayCalendarUrl(isToday);
const authUrl = 'https://auth.worksmobile.com/login/login';
let driver = null;
try {
const chromeOptions = new chrome.Options().addArguments('--start-maximized');
driver = await new Builder().forBrowser(Browser.CHROME).setChromeOptions(chromeOptions).build();
// 先に認証ページを開いて手動ログインを待機
await driver.get(authUrl);
try {
await driver.wait(async () => {
const href = await driver.getCurrentUrl();
return !String(href || '').includes('/login/login');
}, 5 * 60 * 1000);
} catch (_authWaitTimeout) {
// タイムアウト時も遷移先で再認証の可能性があるため処理は継続
}
await driver.get(targetUrl);
// 認証画面が出るケースを想定し、最大5分間カレンダー画面の表示を待機する
await driver.wait(async () => {
return driver.executeScript(() => {
const href = String(window.location.href || '');
const hasCalendarGrid = !!document.querySelector('.time_grid_body, .time_grid_title');
const onBookmarkPage = href.includes('/web/resource/bookmark');
return hasCalendarGrid && onBookmarkPage;
});
}, 5 * 60 * 1000);
const pageSource = await driver.getPageSource();
fs.writeFileSync(outPath, pageSource, 'utf-8');
console.log(`saved: ${path.resolve(outPath)}`);
} finally {
if (driver) {
await driver.quit();
}
}
}
function buildTitle(baseTitle, location) {
const base = String(baseTitle || '設備予約').trim() || '設備予約';
const site = String(location || '').trim();
if (!site) {
return base;
}
return `${site}${base}`;
}
function sanitizeFileName(name) {
return String(name || '')
.replace(/[\\/:*?"<>|]/g, '_')
.trim();
}
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 parseDateFromText(text) {
const raw = String(text || '').trim();
if (!raw) return null;
const normalized = raw
.replace(//g, '(')
.replace(//g, ')')
.replace(/\s+/g, '');
const m = normalized.match(/(\d{4})[\/.\-年](\d{1,2})[\/.\-月](\d{1,2})(?:日)?/);
let date;
if (m) {
const y = Number(m[1]);
const mo = Number(m[2]);
const d = Number(m[3]);
date = new Date(y, mo - 1, d);
if (date.getFullYear() !== y || date.getMonth() !== (mo - 1) || date.getDate() !== d) {
return null;
}
} else {
const parsed = new Date(raw);
if (Number.isNaN(parsed.getTime())) {
return null;
}
date = parsed;
}
return date;
}
function formatJapaneseDateFromText(text) {
const date = parseDateFromText(text);
if (!date) return String(text || '').trim();
const week = ['日', '月', '火', '水', '木', '金', '土'];
const y = date.getFullYear();
const mo = date.getMonth() + 1;
const d = date.getDate();
const w = week[date.getDay()];
return `${y}${mo}${d}日(${w}`;
}
function dateToYYYYMMDD(date) {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
return '';
}
const y = String(date.getFullYear());
const mo = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}${mo}${d}`;
}
async function saveSinglePageA4LandscapePdf(htmlText, outPath) {
const printCss = '<style>@page { size: A4 landscape; margin: 50px; } html, body { margin: 0; padding: 0; }</style>';
const htmlForPrint = htmlText.includes('</head>')
? htmlText.replace('</head>', `${printCss}</head>`)
: `${printCss}${htmlText}`;
const tmpHtmlPath = path.join(os.tmpdir(), `schedule-print-${Date.now()}.html`);
fs.writeFileSync(tmpHtmlPath, htmlForPrint, 'utf-8');
const fileUrl = `file:///${tmpHtmlPath.replace(/\\/g, '/')}`;
let driver = null;
try {
const chromeOptions = new chrome.Options().addArguments(
'--headless=new',
'--disable-gpu',
'--window-size=1600,1000',
'--allow-file-access-from-files'
);
driver = await new Builder().forBrowser(Browser.CHROME).setChromeOptions(chromeOptions).build();
await driver.get(fileUrl);
await driver.wait(async () => {
const state = await driver.executeScript('return document.readyState');
return state === 'complete';
}, 10000);
const scale = await driver.executeScript(() => {
const html = document.documentElement;
const body = document.body;
const contentWidth = Math.max(html.scrollWidth, body.scrollWidth, html.offsetWidth, body.offsetWidth);
const contentHeight = Math.max(html.scrollHeight, body.scrollHeight, html.offsetHeight, body.offsetHeight);
// A4横向き(96dpi換算)へ収める
const pageWidthPx = 1122;
const pageHeightPx = 793;
const paddingPx = 50;
const scaleX = (pageWidthPx - paddingPx * 2) / Math.max(1, contentWidth);
const scaleY = (pageHeightPx - paddingPx * 2) / Math.max(1, contentHeight);
return Math.max(0.1, Math.min(2, scaleX, scaleY));
});
const pdfBase64 = await driver.printPage({
orientation: 'landscape',
background: true,
shrinkToFit: true,
width: 29.7,
height: 21.0,
top: 0,
left: 0,
right: 0,
bottom: 0,
scale,
pageRanges: ['1-1']
});
fs.writeFileSync(outPath, pdfBase64, 'base64');
} finally {
if (driver) {
await driver.quit();
}
if (fs.existsSync(tmpHtmlPath)) {
fs.unlinkSync(tmpHtmlPath);
}
}
}
function toScheduleTableHtml(facilities, result, options) {
const defaultStartHHMM = options?.startHHMM || '07:00';
const defaultEndHHMM = options?.endHHMM || '21:00';
const stepMinutes = Math.max(1, Number(options?.stepMinutes) || 30);
const calendarDate = options?.calendarDate || '';
const facilityList = Array.isArray(facilities) ? facilities : [];
// 予定データ全体から表示範囲を決定(最初の開始-1時間、最後の終了+1時間
let minStart = Infinity;
let maxEnd = -Infinity;
for (const facilityName of facilityList) {
const items = Array.isArray(result[facilityName]) ? result[facilityName] : [];
for (const item of items) {
const timeRange = item?.[0] || '';
const { start, end } = parseTimeRange(timeRange);
const sMin = timeToMinutes(start);
const eMin = timeToMinutes(end);
if (!Number.isFinite(sMin) || !Number.isFinite(eMin) || sMin >= eMin) {
continue;
}
if (sMin < minStart) minStart = sMin;
if (eMin > maxEnd) maxEnd = eMin;
}
}
function minutesToHHMM(totalMinutes) {
const clamped = Math.max(0, Math.min(24 * 60, totalMinutes));
const h = Math.floor(clamped / 60);
const m = clamped % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}
let startHHMM = defaultStartHHMM;
let endHHMM = defaultEndHHMM;
if (Number.isFinite(minStart) && Number.isFinite(maxEnd)) {
const dynamicStart = Math.max(0, minStart - 60);
const dynamicEnd = Math.min(24 * 60, maxEnd + 60);
startHHMM = minutesToHHMM(dynamicStart);
endHHMM = minutesToHHMM(Math.max(dynamicEnd, dynamicStart + stepMinutes));
}
const { startMinutes, endMinutes, slots } = buildTimeSlots(startHHMM, endHHMM, stepMinutes);
const slotCount = slots.length;
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 = `<span class="booked-content">${escapeHtml(content)}</span><br/><span class="booked-content">${escapeHtml(registrant)}</span>`;
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; }
.header-row { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; margin: 0 0 12px; }
h1 { font-size: 28px; margin: 0; text-align: left; line-height: 1.2; }
.calendar-date { font-size: 28px; font-weight: 700; text-align: right; margin: 0; line-height: 1.2; white-space: nowrap; }
.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; white-space: normal; word-break: break-word; }
table.schedule td.booked .booked-content { font-size: 18px; font-weight: 700; }
@media (max-width: 768px) {
.header-row { flex-direction: column; align-items: flex-start; gap: 6px; }
.calendar-date { text-align: left; white-space: normal; }
}
</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>
<div class="header-row">
<h1>${escapeHtml(title)}</h1>
${calendarDate ? `<p class="calendar-date">${escapeHtml(calendarDate)}</p>` : '<p class="calendar-date"></p>'}
</div>
<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;
const dateElement = document.querySelector('strong.date');
const rawDateText = (dateElement?.textContent || '').trim();
const parsedDate = parseDateFromText(rawDateText);
const displayDate = formatJapaneseDateFromText(rawDateText);
const dateKey = dateToYYYYMMDD(parsedDate);
// 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, displayDate, dateKey };
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
printHelp();
return;
}
// 先にカレンダー画面を取得してローカルHTMLへ保存する
await fetchCalendarPageSource(resolveScriptPath('./calendarData.html'), args.today);
const inputPath = resolveScriptPath(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 = resolveScriptPath(args.out);
fs.writeFileSync(outPath, csv, 'utf-8');
} else {
process.stdout.write(csv);
}
} else if (args.format === 'html') {
const outPath = resolveScriptPath(args.out || './schedule_table.html');
const outputTitle = buildTitle(args.title, args.location);
const htmlOut = toScheduleTableHtml(facilities, result, {
title: outputTitle,
calendarDate: analyzed.displayDate || ''
});
fs.writeFileSync(outPath, htmlOut, 'utf-8');
const today = new Date();
const fallbackDateKey = dateToYYYYMMDD(today);
const dateKey = analyzed.dateKey || fallbackDateKey;
const safeTitle = sanitizeFileName(outputTitle) || '設備予約';
const pdfName = `${safeTitle}${dateKey}.pdf`;
const pdfPath = path.join(ROOT_DIR, pdfName);
await saveSinglePageA4LandscapePdf(htmlOut, pdfPath);
console.log(`saved: ${outPath}`);
console.log(`saved: ${pdfPath}`);
} else {
console.log(JSON.stringify(result, null, 2));
}
module.exports = result;
}
main().catch((err) => {
console.error(err);
process.exit(1);
});