584 lines
22 KiB
JavaScript
584 lines
22 KiB
JavaScript
/**
|
||
* merge-logs.js
|
||
* 各部屋のHTMLログを全期間結合し、在室者が1人以上いる区間ごとに折り畳み表示するHTMLを生成する
|
||
*
|
||
* Usage:
|
||
* node merge-logs.js # [数字] で始まる全ディレクトリを自動処理
|
||
* node merge-logs.js <room-directory> # 指定ディレクトリのみ処理
|
||
* node merge-logs.js <room-directory> --output <file.html>
|
||
*
|
||
* 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 [<room-directory>] [--output <file.html>]');
|
||
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, '>')
|
||
.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();
|
||
}
|
||
|
||
/**
|
||
* <div class="hello"> ひとつを解析してコンパクトな情報を返す
|
||
* 元の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<userName> — 現在の在室者
|
||
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 で生成したデータを </script> インジェクション対策でエスケープ
|
||
const sessionsJson = JSON.stringify(sessionsData)
|
||
.replace(/<\/script>/gi, '<\\/script>');
|
||
|
||
const outputHtml = `<!DOCTYPE html>
|
||
<html lang="ja">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>${escapeHtml(roomName)} - 全期間ログ</title>
|
||
<link href="../style/font.css" rel="stylesheet" type="text/css">
|
||
<link href="../style/style.css" rel="stylesheet" type="text/css">
|
||
<link href="../style/style_luvul.css" rel="stylesheet" type="text/css">
|
||
<style>
|
||
body { font-size: 13px; margin: 8px; }
|
||
h1 { font-size: 1.2em; margin: 8px 0 4px; color: #333; }
|
||
.stats { color: #666; font-size: 0.9em; margin: 0 0 12px; }
|
||
#search-bar { margin: 0 0 8px; display:flex; gap:6px; align-items:center; }
|
||
#search-bar input { flex:1; padding:4px 6px; font-size:13px; border:1px solid #ccc; border-radius:3px; }
|
||
#search-bar button { padding:4px 10px; font-size:13px; cursor:pointer; }
|
||
details {
|
||
margin: 4px 0;
|
||
border: 1px solid #ccc;
|
||
border-radius: 3px;
|
||
}
|
||
details.hidden-by-search { display: none; }
|
||
summary {
|
||
cursor: pointer;
|
||
padding: 5px 8px;
|
||
font-weight: bold;
|
||
background: #e8e8f0;
|
||
border-radius: 3px;
|
||
user-select: none;
|
||
list-style: none;
|
||
}
|
||
summary::-webkit-details-marker { display: none; }
|
||
summary::before { content: '▶ '; font-size: 0.75em; }
|
||
details[open] > summary::before { content: '▼ '; }
|
||
details[open] { background: #fff; }
|
||
details.tier-10 > summary { background: #ddeeff; }
|
||
details.tier-50 > summary { background: #d0f0d0; }
|
||
details.tier-100 > summary { background: #fff0b0; }
|
||
details.tier-200 > summary { background: #ffd0a0; }
|
||
.session-body { padding: 4px 6px; }
|
||
.loading { color: #999; font-style: italic; padding: 8px; }
|
||
.chat, .notice {
|
||
padding: 2px 0;
|
||
border-bottom: 1px solid #eee;
|
||
line-height: 1.5;
|
||
word-break: break-word;
|
||
}
|
||
.chat .name { display: inline-block; min-width: 100px; text-align: right; vertical-align: top; }
|
||
.chat .sep { margin: 0 3px; color: #999; }
|
||
.chat .body { margin-right: 6px; }
|
||
.notice { color: #888; font-size: 0.9em; }
|
||
.notice.enter { color: #2a7; }
|
||
.notice.leave { color: #a44; }
|
||
.ts { font-size: 0.8em; color: #aaa; white-space: nowrap; }
|
||
mark { background: #ff9; padding: 0 1px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>${escapeHtml(roomName)} — 全期間ログ</h1>
|
||
<p class="stats">
|
||
ファイル数: ${htmlFiles.length} /
|
||
総エントリ: ${allEntries.length.toLocaleString()} /
|
||
セッション数: ${visibleSessions.length}
|
||
</p>
|
||
<div id="search-bar">
|
||
<input type="text" id="q" placeholder="在室メンバー名で絞り込み…" oninput="filterSessions()">
|
||
<button onclick="expandAll()">全て開く</button>
|
||
<button onclick="collapseAll()">全て閉じる</button>
|
||
</div>
|
||
<div id="session-list"></div>
|
||
|
||
<script>
|
||
const SESSIONS = ${sessionsJson};
|
||
|
||
// ── レンダリング ──────────────────────────────────────────────
|
||
|
||
function esc(s) {
|
||
if (!s) return '';
|
||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
function renderEntries(entries) {
|
||
const parts = [];
|
||
for (const [t, name, color, body, ts] of entries) {
|
||
if (t === 0) {
|
||
// chat
|
||
const col = color ? \` style="color:\${esc(color)}"\` : '';
|
||
parts.push(
|
||
\`<div class="chat"><span class="name"><b\${col}>\${esc(name)}</b></span>\` +
|
||
\`<span class="sep">></span><span class="body">\${body}</span>\` +
|
||
\`<span class="ts">\${esc(ts)}</span></div>\`
|
||
);
|
||
} else if (t === 1) {
|
||
parts.push(\`<div class="notice enter"><span class="ts">\${esc(ts)}</span> \${body}</div>\`);
|
||
} else if (t === 2) {
|
||
parts.push(\`<div class="notice leave"><span class="ts">\${esc(ts)}</span> \${body}</div>\`);
|
||
} else {
|
||
parts.push(\`<div class="notice"><span class="ts">\${esc(ts)}</span> \${body}</div>\`);
|
||
}
|
||
}
|
||
return parts.join('');
|
||
}
|
||
|
||
// ── 初期DOM構築(summaryのみ、bodyは空) ─────────────────────
|
||
|
||
const list = document.getElementById('session-list');
|
||
const detailsEls = [];
|
||
|
||
for (let i = 0; i < SESSIONS.length; i++) {
|
||
const s = SESSIONS[i];
|
||
const det = document.createElement('details');
|
||
det.dataset.idx = i;
|
||
const cc = SESSIONS[i].chatCount;
|
||
if (cc >= 200) det.classList.add('tier-200');
|
||
else if (cc >= 100) det.classList.add('tier-100');
|
||
else if (cc >= 50) det.classList.add('tier-50');
|
||
else if (cc >= 10) det.classList.add('tier-10');
|
||
|
||
const sum = document.createElement('summary');
|
||
sum.textContent = s.title;
|
||
det.appendChild(sum);
|
||
|
||
const body = document.createElement('div');
|
||
body.className = 'session-body';
|
||
body.innerHTML = '<span class="loading">読み込み中…</span>';
|
||
det.appendChild(body);
|
||
|
||
det.addEventListener('toggle', function onToggle() {
|
||
if (!det.open) return;
|
||
// 初回展開時のみレンダリング
|
||
if (det.dataset.rendered) return;
|
||
det.dataset.rendered = '1';
|
||
body.innerHTML = renderEntries(SESSIONS[det.dataset.idx].entries);
|
||
});
|
||
|
||
list.appendChild(det);
|
||
detailsEls.push(det);
|
||
}
|
||
|
||
// ── 絞り込み ─────────────────────────────────────────────────
|
||
|
||
function filterSessions() {
|
||
const q = document.getElementById('q').value.trim().toLowerCase();
|
||
for (const det of detailsEls) {
|
||
const title = SESSIONS[det.dataset.idx].title.toLowerCase();
|
||
if (!q || title.includes(q)) {
|
||
det.classList.remove('hidden-by-search');
|
||
} else {
|
||
det.classList.add('hidden-by-search');
|
||
}
|
||
}
|
||
}
|
||
|
||
function expandAll() {
|
||
for (const det of detailsEls) {
|
||
if (!det.classList.contains('hidden-by-search')) det.open = true;
|
||
}
|
||
}
|
||
function collapseAll() {
|
||
for (const det of detailsEls) det.open = false;
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>`;
|
||
|
||
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.`);
|
||
}
|