/** * merge-logs.js * 各部屋のHTMLログを全期間結合し、在室者が1人以上いる区間ごとに折り畳み表示するHTMLを生成する * * Usage: * node merge-logs.js # [数字] で始まる全ディレクトリを自動処理 * node merge-logs.js # 指定ディレクトリのみ処理 * node merge-logs.js --output * * Examples: * node merge-logs.js * node merge-logs.js "[188291]版権百合部屋" * node merge-logs.js "[188291]版権百合部屋" --output merged.html */ const fs = require('fs'); const path = require('path'); const parser = require('node-html-parser'); // ─── CLI ───────────────────────────────────────────────────────────────────── const args = process.argv.slice(2); // 引数なし → [数字] で始まる全ディレクトリを自動収集 let targets = []; // [{ roomDir, outputFile }] if (args.length === 0) { const base = process.cwd(); const dirs = fs.readdirSync(base).filter(name => { if (!/^\[\d/.test(name)) return false; return fs.statSync(path.join(base, name)).isDirectory(); }).sort(); if (dirs.length === 0) { console.error('No directories starting with [数字] found in current directory.'); process.exit(1); } console.log(`Auto-detected ${dirs.length} room directories.`); for (const d of dirs) { const roomDir = path.join(base, d); targets.push({ roomDir, outputFile: path.join(roomDir, '_merged.html') }); } } else { let roomDir = null; let outputFile = null; for (let i = 0; i < args.length; i++) { if (args[i] === '--output' && args[i + 1]) { outputFile = args[++i]; } else { roomDir = args[i]; } } if (!roomDir) { console.error('Usage: node merge-logs.js [] [--output ]'); process.exit(1); } roomDir = path.resolve(roomDir); if (!fs.existsSync(roomDir)) { console.error(`Directory not found: ${roomDir}`); process.exit(1); } if (!outputFile) outputFile = path.join(roomDir, '_merged.html'); targets.push({ roomDir, outputFile }); } // ─── Parse helpers ─────────────────────────────────────────────────────────── function processRoom(roomDir, outputFile) { const roomName = path.basename(roomDir); function escapeHtml(s) { if (!s) return ''; return s .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } /** * タイムスタンプ文字列を Date に変換 * フル形式: "(YYYY/M/D H:MM:SS)" * 年なし形式: "(M/D H:MM:SS)" → fileStartDate の年から推定(年またぎ対応) * * @param {string} text * @param {Date|null} fileStartDate - ファイル名から取得した開始日(年推定に使用) * @returns {Date|null} */ function parseTimestamp(text, fileStartDate) { // フル形式 (YYYY/M/D H:MM:SS) const full = text.match(/\((\d{4})\/(\d{1,2})\/(\d{1,2})\s+(\d{1,2}):(\d{2}):(\d{2})\)/); if (full) { return new Date( parseInt(full[1]), parseInt(full[2]) - 1, parseInt(full[3]), parseInt(full[4]), parseInt(full[5]), parseInt(full[6]) ); } // 年なし形式 (M/D H:MM:SS) — fileStartDate の年を使って補完 const noYear = text.match(/\((\d{1,2})\/(\d{1,2})\s+(\d{1,2}):(\d{2}):(\d{2})\)/); if (noYear && fileStartDate) { const baseYear = fileStartDate.getFullYear(); const mo = parseInt(noYear[1]) - 1; const da = parseInt(noYear[2]); const hh = parseInt(noYear[3]); const mm = parseInt(noYear[4]); const ss = parseInt(noYear[5]); // まず同年で試す const candidate = new Date(baseYear, mo, da, hh, mm, ss); // ファイル開始日より 30日以上前になるなら「年またぎ=翌年」と判断 if (candidate < fileStartDate && (fileStartDate - candidate) > 30 * 24 * 3600 * 1000) { return new Date(baseYear + 1, mo, da, hh, mm, ss); } return candidate; } return null; } /** * タイムスタンプをソートキー文字列に変換(重複排除にも使用) */ function tsKey(date) { if (!date) return ''; return date.getTime().toString(); } /** *
ひとつを解析してコンパクトな情報を返す * 元のHTMLは保持せず、表示に必要な情報のみ抽出する(ファイルサイズ削減) * * @param {object} divNode * @param {Date|null} fileStartDate - ファイル名から取得した開始日(年なしタイムスタンプの推定に使用) * @returns {{ * key: string, // 重複排除キー * tsNum: number, // ソート用数値タイムスタンプ * timestamp: Date|null, * tsStr: string, // 表示用タイムスタンプ文字列 * type: 'enter'|'leave'|'notice'|'chat', * userName: string|null, * nameColor: string|null, * body: string, // チャット本文 (HTMLエスケープ済み) * isNotice: boolean, * }} */ function parseEntry(divNode, fileStartDate) { const tds = divNode.querySelectorAll('td'); // タイムスタンプ const smallEl = divNode.querySelector('small.choiusu'); const tsText = smallEl ? smallEl.text.trim() : ''; const timestamp = tsText ? parseTimestamp(tsText, fileStartDate) : null; // 送信者セル (最初の td) const senderTd = tds[0]; const senderText = senderTd ? senderTd.text.trim() : ''; const isNotice = senderText === 'おしらせ'; // 本文セル const contentTd = tds[2]; if (!isNotice) { // 通常チャット const senderFont = senderTd ? senderTd.querySelector('font.nobr') : null; const userName = senderFont ? senderFont.text.trim() : (senderText || null); const nameColor = senderFont ? (senderFont.getAttribute('color') || null) : null; // chatbody span があればそのテキスト、なければ content 全体 const chatBodyEl = contentTd ? contentTd.querySelector('span.chatbody') : null; const body = chatBodyEl ? chatBodyEl.innerHTML // 元のHTML(絵文字・ルビなど保持) : (contentTd ? contentTd.innerHTML : ''); const key = `chat:${userName}:${tsKey(timestamp)}`; return { key, tsNum: timestamp ? timestamp.getTime() : 0, timestamp, tsStr: tsText, type: 'chat', userName, nameColor, body, isNotice: false, }; } // おしらせ const nameFont = contentTd ? contentTd.querySelector('font.nobr') : null; const userName = nameFont ? nameFont.text.trim() : null; const nameColor = nameFont ? (nameFont.getAttribute('color') || null) : null; const contentText = contentTd ? contentTd.text : ''; const body = contentTd ? contentTd.innerHTML : ''; let type = 'notice'; if (/入室しました/.test(contentText)) type = 'enter'; else if (/退室しました|強制退室|部屋から追い出されました/.test(contentText)) type = 'leave'; const key = `${type}:${userName}:${tsKey(timestamp)}`; return { key, tsNum: timestamp ? timestamp.getTime() : 0, timestamp, tsStr: tsText, type, userName, nameColor, body, isNotice: true, }; } // ─── Load and parse all HTML files ─────────────────────────────────────────── const htmlFiles = fs.readdirSync(roomDir) .filter(f => f.endsWith('.html') && !f.startsWith('_')) .sort(); if (htmlFiles.length === 0) { console.error('No HTML files found in directory.'); process.exit(1); } console.log(`Found ${htmlFiles.length} HTML files in: ${roomName}`); // 重複排除用Set const seenKeys = new Set(); let allEntries = []; for (const file of htmlFiles) { const filePath = path.join(roomDir, file); const raw = fs.readFileSync(filePath, 'utf8'); const dom = parser.parse(raw); // ファイル名 "YYYYMMDD HHMM ~ の過去ログ.html" から開始日時を取得(年なしタイムスタンプの推定に使用) const fnameDateM = file.match(/^(\d{4})(\d{2})(\d{2})\s+(\d{2})(\d{2})/); const fileStartDate = fnameDateM ? new Date( parseInt(fnameDateM[1]), parseInt(fnameDateM[2]) - 1, parseInt(fnameDateM[3]), parseInt(fnameDateM[4]), parseInt(fnameDateM[5]) ) : null; const entries = dom.querySelectorAll('div.hello'); for (const entry of entries) { const parsed = parseEntry(entry, fileStartDate); // タイムスタンプ+種別+ユーザー名が同じエントリは重複とみなす if (parsed.key && seenKeys.has(parsed.key)) continue; if (parsed.key) seenKeys.add(parsed.key); allEntries.push(parsed); } } const beforeDedup = allEntries.length; console.log(`Total entries after dedup: ${allEntries.length} (removed ${seenKeys.size - allEntries.length} duplicates)`); // ─── Sort by timestamp ─────────────────────────────────────────────────────── allEntries.sort((a, b) => { if (a.tsNum === 0 && b.tsNum === 0) return 0; if (a.tsNum === 0) return -1; if (b.tsNum === 0) return 1; return a.tsNum - b.tsNum; }); // ─── Group into sessions (occupancy > 0) ───────────────────────────────────── /** * セッション分割のルール: * * [主ルール] 入退室イベントによる終了 * - 「入室しました」でセッション開始・在室者セットに追加 * ※同一ユーザーの再入室(ページ境界での重複記録)はカウントを増やさない * - 「退室しました/強制退室」で在室者から削除 → 全員退室でセッション終了 * * [補助ルール] 時間ギャップによるフォールバック終了 * - アクティブなセッション内で GAP_THRESHOLD_MS 以上の無発言ギャップが * あった場合は強制終了してリセット。 * - luvul は10分無操作で強制退室するため、正常なログのセッション内最大 * ギャップは4時間未満(199件の正規終了セッションの実測値より)。 * 4時間を超えるギャップは「退室ログが欠損した異常ケース」と判断する。 */ const GAP_THRESHOLD_MS = 4 * 60 * 60 * 1000; // 4時間 const sessions = []; let currentSession = null; // Set — 現在の在室者 const occupantSet = new Set(); let prevTimestamp = null; function flushSession() { if (currentSession && currentSession.entries.length > 0) { sessions.push(currentSession); } currentSession = null; occupantSet.clear(); } for (const entry of allEntries) { const { type, userName } = entry; // 補助ルール: アクティブセッション内の時間ギャップ超過 → 強制終了 if ( currentSession && entry.timestamp && prevTimestamp && entry.timestamp - prevTimestamp > GAP_THRESHOLD_MS ) { flushSession(); } if (entry.timestamp) prevTimestamp = entry.timestamp; if (type === 'enter' && userName) { // 同一ユーザーがすでに在室中なら再入室無視(ページ境界対策) if (!occupantSet.has(userName)) { occupantSet.add(userName); } if (!currentSession) { currentSession = { startTime: entry.timestamp, endTime: entry.timestamp, occupantsEver: new Set(), entries: [], }; } currentSession.occupantsEver.add(userName); } if (currentSession) { currentSession.entries.push(entry); if (entry.timestamp) currentSession.endTime = entry.timestamp; if (type === 'leave' && userName) { occupantSet.delete(userName); // 全員退室したらセッション終了 if (occupantSet.size === 0) { flushSession(); } } } } // 終端処理(退室なしに終わるセッション) if (currentSession && currentSession.entries.length > 0) { sessions.push(currentSession); } console.log(`Sessions detected: ${sessions.length}`); // ─── Format date ───────────────────────────────────────────────────────────── function pad(n) { return String(n).padStart(2, '0'); } function formatDate(d) { if (!d) return '?'; return `${d.getFullYear()}/${pad(d.getMonth()+1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; } // ─── Serialize sessions to compact JS data ──────────────────────────────────── /** * エントリを最小限のデータ配列にシリアライズする。 * HTML タグは一切含めず、ブラウザ側 JS で DOM 生成する。 * * エントリフォーマット (配列): * chat: [0, name, color|null, bodyHtml, tsStr] * enter: [1, name, color|null, bodyHtml, tsStr] * leave: [2, name, color|null, bodyHtml, tsStr] * notice: [3, null, null, bodyHtml, tsStr] */ function typeCode(e) { if (!e.isNotice) return 0; if (e.type === 'enter') return 1; if (e.type === 'leave') return 2; return 3; } const visibleSessions = sessions.filter(s => s.entries.some(e => e.type === 'chat')); // セッションデータをJS配列として構築 const sessionsData = visibleSessions.map((session, idx) => { const members = [...session.occupantsEver].join('、'); const start = formatDate(session.startTime); const end = formatDate(session.endTime); const chatCount = session.entries.filter(e => e.type === 'chat').length; const title = `【${idx + 1}】 ${start} ~ ${end} [${chatCount}件] 在室: ${members}`; const entries = session.entries.map(e => [ typeCode(e), e.userName || null, e.nameColor || null, e.body || '', e.tsStr || '', ]); return { title, chatCount, entries }; }); // ─── Build output HTML ──────────────────────────────────────────────────────── // JSON.stringify で生成したデータを インジェクション対策でエスケープ const sessionsJson = JSON.stringify(sessionsData) .replace(/<\/script>/gi, '<\\/script>'); const outputHtml = ` ${escapeHtml(roomName)} - 全期間ログ

${escapeHtml(roomName)} — 全期間ログ

ファイル数: ${htmlFiles.length} / 総エントリ: ${allEntries.length.toLocaleString()} / セッション数: ${visibleSessions.length}

`; fs.writeFileSync(outputFile, outputHtml, 'utf8'); console.log(`\nOutput: ${outputFile}`); console.log(`File size: ${(fs.statSync(outputFile).size / 1024 / 1024).toFixed(1)} MB`); console.log(`Sessions (all): ${sessions.length}, Sessions (with chat): ${visibleSessions.length}`); } // end processRoom // ─── Main ───────────────────────────────────────────────────────────────────── for (let i = 0; i < targets.length; i++) { const { roomDir, outputFile } = targets[i]; if (targets.length > 1) { console.log(`\n[${i + 1}/${targets.length}] ${path.basename(roomDir)}`); console.log('='.repeat(60)); } try { processRoom(roomDir, outputFile); } catch (err) { console.error(`Error processing ${path.basename(roomDir)}: ${err.message}`); } } if (targets.length > 1) { console.log(`\n${'='.repeat(60)}`); console.log(`Done. Processed ${targets.length} rooms.`); }