Files
luvul_crawl/merge-logs.js
T
tetsuya-kitayama b627224308 init
2026-05-18 10:19:19 +09:00

690 lines
26 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
/**
* 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* タイムスタンプ文字列を 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, 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; }
.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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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">&gt;</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 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.`);
generateIndex(targets);
}
// ─── Generate index.html ──────────────────────────────────────────────────────
function generateIndex(targets) {
const base = process.cwd();
const indexFile = path.join(base, 'index.html');
// 各部屋のメタ情報を収集
const rooms = targets.map(({ roomDir, outputFile }) => {
const roomName = path.basename(roomDir);
const mergedPath = outputFile;
const relPath = path.relative(base, mergedPath).replace(/\\/g, '/');
const exists = require('fs').existsSync(mergedPath);
// _merged.html から統計を取得(簡易スキャン)
let fileCount = 0, entryCount = 0, sessionCount = 0;
if (exists) {
const content = fs.readFileSync(mergedPath, 'utf8');
const m = content.match(/ファイル数: ([\d,]+) \s*総エントリ: ([\d,]+) \s*セッション数: ([\d,]+)/);
if (m) {
fileCount = parseInt(m[1].replace(/,/g, ''));
entryCount = parseInt(m[2].replace(/,/g, ''));
sessionCount = parseInt(m[3].replace(/,/g, ''));
}
}
return { roomName, relPath, exists, fileCount, entryCount, sessionCount };
});
const rowsHtml = rooms.map(r => {
const nameEsc = r.roomName.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const link = r.exists
? `<a href="${r.relPath}">${nameEsc}</a>`
: `<span style="color:#999">${nameEsc}</span>`;
return `<tr>
<td>${link}</td>
<td class="num">${r.fileCount || '-'}</td>
<td class="num">${r.entryCount ? r.entryCount.toLocaleString() : '-'}</td>
<td class="num">${r.sessionCount || '-'}</td>
</tr>`;
}).join('\n');
const totalEntries = rooms.reduce((s, r) => s + (r.entryCount || 0), 0);
const totalSessions = rooms.reduce((s, r) => s + (r.sessionCount || 0), 0);
const now = new Date();
const updatedAt = `${now.getFullYear()}/${String(now.getMonth()+1).padStart(2,'0')}/${String(now.getDate()).padStart(2,'0')} ${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`;
const indexHtml = `<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>luvul ログ一覧</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: 16px; }
h1 { font-size: 1.3em; margin: 0 0 6px; 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; max-width:320px; padding:4px 6px; font-size:13px; border:1px solid #ccc; border-radius:3px; }
table { border-collapse: collapse; width: 100%; max-width: 820px; }
th, td { border: 1px solid #ddd; padding: 5px 8px; }
th { background: #e8e8f0; text-align: left; white-space: nowrap; }
td.num { text-align: right; white-space: nowrap; color: #555; }
tr:nth-child(even) td { background: #fafafa; }
tr:hover td { background: #f0f4ff; }
tr.hidden-by-search { display: none; }
a { color: #25a; text-decoration: none; }
a:hover { text-decoration: underline; }
.updated { color: #aaa; font-size: 0.85em; margin-top: 12px; }
</style>
</head>
<body>
<h1>luvul ログ一覧</h1>
<p class="stats">
部屋数: ${rooms.length}
総エントリ: ${totalEntries.toLocaleString()}
総セッション数: ${totalSessions}
</p>
<div id="search-bar">
<input type="text" id="q" placeholder="部屋名で絞り込み…" oninput="filterRooms()">
</div>
<table>
<thead>
<tr>
<th>部屋名</th>
<th>ファイル数</th>
<th>総エントリ</th>
<th>セッション数</th>
</tr>
</thead>
<tbody id="room-list">
${rowsHtml}
</tbody>
</table>
<p class="updated">最終更新: ${updatedAt}</p>
<script>
function filterRooms() {
const q = document.getElementById('q').value.trim().toLowerCase();
for (const tr of document.querySelectorAll('#room-list tr')) {
const text = tr.textContent.toLowerCase();
tr.classList.toggle('hidden-by-search', !!q && !text.includes(q));
}
}
</script>
</body>
</html>`;
fs.writeFileSync(indexFile, indexHtml, 'utf8');
console.log(`\nIndex: ${indexFile}`);
}