ひとつを解析してコンパクトな情報を返す
* 元の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.`);
}