Files

245 lines
19 KiB
JavaScript

'use strict';
// ============================================================================
// 사이트 접속 통계 모듈 (v1.0.2)
//
// 두 가지 경로로 통계를 모은다.
// 1) 서버 로그(측정): 우리가 관리하는 nginx 에 `wmstats` 로그 형식을 추가해 요청마다
// 수신 바이트($request_length)·송신 바이트($bytes_sent)·상태·경로·리퍼러·UA 를 기록하고,
// 수집기가 주기적으로 새로 쌓인 부분만 읽어 사이트별·일자별로 집계한다.
// - 서비스 서버 자체(로컬 nginx): /var/log/nginx/webmaker-sites.log 한 파일, URI 의 /<32hex>/ 로 사이트 식별
// - 원격 배포 서버(SSH, sudo 허용): 사이트 vhost 마다 /var/log/nginx/wh-<id>.access.log
// 2) 브라우저 비컨(추정): 모든 HTML 에 삽입되는 /wm-stats.js 가 페이지를 열 때 한 번
// POST /api/stats/beacon 으로 경로·리퍼러·화면·전송량(Performance API transferSize)을 보고한다.
// 로그를 읽을 수 없는 서버(가져온 기존 사이트의 원본 서버 등)에서도 방문 통계를 얻기 위한 보조 경로.
//
// 저장: data/stats/<siteId>.json
// { days: { 'YYYY-MM-DD': { req, pv, bytesIn, bytesOut, bots, v: {해시: 1}, vOver,
// pages: {경로: 조회수}, refs: {출처: 수}, hosts: {호스트: 수},
// b: { pv, v: {해시: 1}, vOver, bytesOut, screens: {구간: 수}, langs: {언어: 수} } } },
// updatedAt }
// v(방문자 집합)는 "일자+IP+UA" 의 해시만 저장하고(원본 IP 는 저장하지 않음) 하루 MAX_VISITOR_KEYS 개까지만 보관,
// 넘치면 vOver 에 개수만 더한다. 방문자 수 = Object.keys(v).length + vOver.
// ============================================================================
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
const STATS_DIR = path.join(DATA_ROOT, 'stats');
const OFFSETS_FILE = path.join(STATS_DIR, '_offsets.json'); // 로그 파일별 읽은 위치 { key: { inode, offset } }
fs.mkdirSync(STATS_DIR, { recursive: true });
const MAX_VISITOR_KEYS = 5000; // 하루에 보관하는 방문자 해시 개수 상한 (그 이상은 vOver 로 개수만)
const MAX_DAYS = 400; // 보관 일수 (누적치는 totals 에 따로 합산해 두므로 오래된 일자는 버려도 됨)
const TOP_KEYS = 60; // 페이지/유입 경로 집계에 보관하는 키 개수 상한
const SITE_ID_RE = /^\/([a-f0-9]{32})(?:\/|$)/;
// 검색엔진·크롤러·모니터링 봇 UA. 봇 요청은 bots 로만 세고 방문자·페이지뷰에서 뺀다.
const BOT_RE = /bot|crawl|spider|slurp|facebookexternalhit|preview|monitor|uptime|headless|curl\/|wget\/|python-requests|go-http-client|java\/|libwww|okhttp|scrapy|ahrefs|semrush|mj12|dotbot|petalbot|yandex|baidu|bingpreview|lighthouse|pingdom/i;
// 페이지로 간주하는 요청: HTML 문서(확장자 없음 포함). 이미지·CSS·JS 는 요청(req)·바이트에만 반영.
const ASSET_EXT_RE = /\.(css|js|mjs|json|png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf|eot|map|mp4|webm|mp3|pdf|zip|txt|xml)$/i;
/** 서울 기준 날짜 문자열 'YYYY-MM-DD' (통계 일자는 모두 KST) */
function kstDate(ts) { const d = new Date(ts || Date.now()); return new Date(d.getTime() + 9 * 3600 * 1000).toISOString().slice(0, 10); }
/** 방문자 식별 해시: 일자 + 서버 비밀 + IP + UA. 원본 IP 는 저장하지 않는다. */
let SALT = null;
function salt() { if (SALT) return SALT; try { SALT = fs.readFileSync(path.join(DATA_ROOT, 'session.secret'), 'utf8').trim().slice(0, 32); } catch (_) { SALT = 'wm'; } return SALT; }
function visitorHash(day, ip, ua) { return crypto.createHash('sha1').update(`${day}|${salt()}|${ip || ''}|${(ua || '').slice(0, 200)}`).digest('hex').slice(0, 16); }
// ---------- 저장소 ----------
function fileOf(siteId) { return path.join(STATS_DIR, `${siteId}.json`); }
function load(siteId) { try { return JSON.parse(fs.readFileSync(fileOf(siteId), 'utf8')); } catch (_) { return { days: {}, totals: { req: 0, pv: 0, bytesIn: 0, bytesOut: 0, bots: 0, bpv: 0, bBytesOut: 0 }, updatedAt: null }; } }
function save(siteId, d) {
// 오래된 일자는 totals 에 합산한 뒤 버린다 (파일 크기 상한)
const keys = Object.keys(d.days).sort();
while (keys.length > MAX_DAYS) { const k = keys.shift(); const x = d.days[k]; d.totals.req += x.req || 0; d.totals.pv += x.pv || 0; d.totals.bytesIn += x.bytesIn || 0; d.totals.bytesOut += x.bytesOut || 0; d.totals.bots += x.bots || 0; d.totals.bpv += (x.b && x.b.pv) || 0; d.totals.bBytesOut += (x.b && x.b.bytesOut) || 0; d.totals.visitors = (d.totals.visitors || 0) + visitors(x); d.totals.bVisitors = (d.totals.bVisitors || 0) + bVisitors(x); delete d.days[k]; } // v1.3.17 (B31): 방문자 수도 누적에 남긴다 (일별 방문자 합 — 전체 기간 지표와 같은 기준)
d.updatedAt = new Date().toISOString();
const tmp = fileOf(siteId) + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(d)); fs.renameSync(tmp, fileOf(siteId));
}
function remove(siteId) { try { fs.unlinkSync(fileOf(siteId)); } catch (_) {} }
/** 일자 레코드(없으면 생성) */
function dayRec(d, day) {
if (!d.days[day]) d.days[day] = { req: 0, pv: 0, bytesIn: 0, bytesOut: 0, bots: 0, v: {}, vOver: 0, pages: {}, refs: {}, hosts: {}, b: { pv: 0, v: {}, vOver: 0, bytesOut: 0, screens: {}, langs: {} } };
return d.days[day];
}
/** 집합에 방문자 해시를 넣는다(상한 초과 시 개수만) */
function addVisitor(rec, hash) { if (rec.v[hash]) return; if (Object.keys(rec.v).length >= MAX_VISITOR_KEYS) { rec.vOver += 1; return; } rec.v[hash] = 1; }
/** 상위 N 개만 보관하는 카운터 증가 */
function bump(map, key, n = 1) {
if (!key) return; key = String(key).slice(0, 200);
if (map[key] === undefined && Object.keys(map).length >= TOP_KEYS) { const min = Object.entries(map).sort((a, b) => a[1] - b[1])[0]; if (min && min[1] <= n) delete map[min[0]]; else return; }
map[key] = (map[key] || 0) + n;
}
/** 리퍼러를 "출처 호스트" 로 정규화. 같은 사이트 안의 이동은 (직접/내부) 로 묶는다. */
function refHost(ref, selfHosts) { if (!ref || ref === '-') return '(직접 접속)'; try { const h = new URL(ref).hostname.toLowerCase(); if (selfHosts && selfHosts.has(h)) return '(사이트 내 이동)'; return h; } catch (_) { return '(기타)'; } }
/** 사이트 안 경로로 정규화: /<id>/about.html → /about.html, 쿼리 제거 */
function pagePath(uri, siteId) { let p = String(uri || '/').split('?')[0]; if (siteId && p.startsWith('/' + siteId)) p = p.slice(siteId.length + 1) || '/'; return p || '/'; }
// ---------- 기록 ----------
// 짧은 시간에 여러 건이 들어와도 사이트당 한 번만 파일을 쓰도록 지연 저장한다.
const dirty = new Map(); // siteId -> data
let flushTimer = null;
function markDirty(siteId, d) { dirty.set(siteId, d); if (!flushTimer) flushTimer = setTimeout(flush, 3000); }
function flush() { flushTimer = null; for (const [id, d] of dirty) { try { save(id, d); } catch (_) {} } dirty.clear(); }
function current(siteId) { return dirty.get(siteId) || load(siteId); }
/**
* 서버 로그 한 줄 반영.
* @param {string} siteId
* @param {object} e { ts(ms), ip, host, method, uri, status, reqLen, bytesSent, referer, ua }
*/
function recordLog(siteId, e) {
const d = current(siteId); const day = kstDate(e.ts); const rec = dayRec(d, day);
rec.req += 1; rec.bytesIn += e.reqLen || 0; rec.bytesOut += e.bytesSent || 0;
bump(rec.hosts, (e.host || '').toLowerCase());
const bot = BOT_RE.test(e.ua || '');
if (bot) { rec.bots += 1; markDirty(siteId, d); return; }
const p = pagePath(e.uri, siteId);
const isPage = e.method === 'GET' && (e.status === 200 || e.status === 304) && !ASSET_EXT_RE.test(p);
if (isPage) { rec.pv += 1; addVisitor(rec, visitorHash(day, e.ip, e.ua)); bump(rec.pages, p); bump(rec.refs, refHost(e.referer, new Set([(e.host || '').toLowerCase()]))); }
markDirty(siteId, d);
}
/**
* 브라우저 비컨 반영. 로그가 없는 서버의 보조 통계.
* @param {object} b { ip, ua, path, ref, host, transfer(바이트), lang, w, h }
*/
function recordBeacon(siteId, b) {
if (BOT_RE.test(b.ua || '')) return;
const d = current(siteId); const day = kstDate(); const rec = dayRec(d, day);
rec.b.pv += 1; rec.b.bytesOut += Math.max(0, Math.min(50 * 1024 * 1024, Number(b.transfer) || 0));
const bh = visitorHash(day, b.ip, b.ua); if (!rec.b.v[bh]) { if (Object.keys(rec.b.v).length >= MAX_VISITOR_KEYS) rec.b.vOver += 1; else rec.b.v[bh] = 1; }
bump(rec.hosts, (b.host || '').toLowerCase());
const w = Number(b.w) || 0; bump(rec.b.screens, w ? (w < 600 ? '모바일' : (w < 1024 ? '태블릿' : 'PC')) : '알 수 없음');
bump(rec.b.langs, String(b.lang || '').slice(0, 10) || '-');
// 로그를 못 읽는 사이트는 비컨 페이지뷰를 pages/refs 에도 반영 (서버 로그가 있으면 중복이지만 pv 자체는 분리 보관)
bump(rec.pages, pagePath(b.path, siteId)); bump(rec.refs, refHost(b.ref, new Set([(b.host || '').toLowerCase()])));
markDirty(siteId, d);
}
// ---------- nginx 로그 파싱 ----------
/**
* `wmstats` 로그 형식(deploy/nginx-wmstats.conf) 한 줄을 파싱한다.
* $msec|$remote_addr|$host|$request_method|$request_uri|$status|$request_length|$bytes_sent|$http_referer|$http_user_agent
* UA 안에 '|' 가 있을 수 있으므로 앞 9개만 나누고 나머지는 UA 로 합친다.
*/
function parseLine(line) {
const f = line.split('|'); if (f.length < 10) return null;
const ts = Math.round(parseFloat(f[0]) * 1000); if (!Number.isFinite(ts)) return null;
return { ts, ip: f[1], host: f[2], method: f[3], uri: f[4], status: parseInt(f[5], 10) || 0, reqLen: parseInt(f[6], 10) || 0, bytesSent: parseInt(f[7], 10) || 0, referer: f[8], ua: f.slice(9).join('|') };
}
function loadOffsets() { try { return JSON.parse(fs.readFileSync(OFFSETS_FILE, 'utf8')); } catch (_) { return {}; } }
function saveOffsets(o) { const tmp = OFFSETS_FILE + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(o)); fs.renameSync(tmp, OFFSETS_FILE); }
/**
* 로그 텍스트 덩어리를 집계한다. siteOf(entry) 가 사이트 ID 를 돌려주지 않으면 건너뛴다.
* @returns {number} 반영한 줄 수
*/
function ingestText(text, siteOf) {
let n = 0;
for (const line of text.split('\n')) { if (!line) continue; const e = parseLine(line); if (!e) continue; const id = siteOf(e); if (!id) continue; recordLog(id, e); n += 1; }
flush();
return n;
}
/**
* 로컬 로그 파일에서 지난번 이후 새로 쌓인 부분만 읽어 반영한다.
* 파일이 교체(로테이션)되어 inode 가 바뀌거나 크기가 줄었으면 처음부터 다시 읽는다.
* 로테이션 직후에는 이전 파일(.1)의 꼬리를 놓칠 수 있는데, 5분 주기 수집에서 그 손실은 작다고 보고 단순화했다.
*/
function collectLocalFile(logPath, siteOf, key) {
let st; try { st = fs.statSync(logPath); } catch (_) { return { ok: false, lines: 0, detail: '로그 파일 없음' }; }
const offs = loadOffsets(); const k = key || logPath; const prev = offs[k] || { inode: null, offset: 0 };
let start = prev.offset; if (prev.inode !== st.ino || st.size < prev.offset) start = 0;
if (st.size === start) { offs[k] = { inode: st.ino, offset: start }; saveOffsets(offs); return { ok: true, lines: 0 }; }
const fd = fs.openSync(logPath, 'r'); const len = st.size - start; const buf = Buffer.alloc(Math.min(len, 64 * 1024 * 1024));
const read = fs.readSync(fd, buf, 0, buf.length, start); fs.closeSync(fd);
let text = buf.slice(0, read).toString('utf8'); let consumed = read;
// 마지막 줄이 아직 덜 쓰였을 수 있으므로 개행까지만 처리
const nl = text.lastIndexOf('\n'); if (nl < 0) return { ok: true, lines: 0 }; if (nl < text.length - 1) { consumed = Buffer.byteLength(text.slice(0, nl + 1)); text = text.slice(0, nl + 1); }
const lines = ingestText(text, siteOf);
offs[k] = { inode: st.ino, offset: start + consumed }; saveOffsets(offs);
return { ok: true, lines };
}
/** 원격 로그용: 외부에서 받아온 텍스트와 새 오프셋을 기록 */
function offsetOf(key) { return loadOffsets()[key] || { inode: null, offset: 0 }; }
function setOffset(key, inode, offset) { const o = loadOffsets(); o[key] = { inode, offset }; saveOffsets(o); }
// ---------- 저장 공간 ----------
/** 디렉터리 총 바이트 (심볼릭 링크 제외) */
function dirSize(dir, skip = new Set()) {
let total = 0;
try { for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { if (skip.has(ent.name)) continue; const p = path.join(dir, ent.name); try { if (ent.isDirectory()) total += dirSize(p, skip); else if (ent.isFile()) total += fs.statSync(p).size; } catch (_) {} } } catch (_) {}
return total;
}
function fileSize(p) { try { return fs.statSync(p).size; } catch (_) { return 0; } }
// ---------- 요약 ----------
const sum = (recs, f) => recs.reduce((a, r) => a + (f(r) || 0), 0);
const visitors = (r) => (r ? Object.keys(r.v || {}).length + (r.vOver || 0) : 0);
const bVisitors = (r) => (r && r.b ? Object.keys(r.b.v || {}).length + (r.b.vOver || 0) : 0);
/**
* 사용자 화면용 요약.
* @param {string} siteId
* @param {object} paths { siteDir, siteJson, inquiriesJson, gitDir }
* @returns 오늘/어제/7일/30일/이번 달/누적 방문자·페이지뷰·요청·바이트, 일별 시계열(최근 30일), 상위 페이지/유입, 기기, 저장 공간
*/
function summary(siteId, paths = {}) {
const d = load(siteId); const days = d.days; const today = kstDate(); const month = today.slice(0, 7);
const dayKeys = Object.keys(days).sort();
const lastN = (n) => { const out = []; for (let i = n - 1; i >= 0; i--) { const k = kstDate(Date.now() - i * 86400000); out.push([k, days[k]]); } return out; };
const agg = (recs) => {
const list = recs.filter(Boolean);
return { visitors: sum(list, visitors), pv: sum(list, (r) => r.pv), req: sum(list, (r) => r.req), bots: sum(list, (r) => r.bots), bytesIn: sum(list, (r) => r.bytesIn), bytesOut: sum(list, (r) => r.bytesOut), beaconPv: sum(list, (r) => r.b && r.b.pv), beaconVisitors: sum(list, bVisitors), beaconBytesOut: sum(list, (r) => r.b && r.b.bytesOut) };
};
const all = agg(dayKeys.map((k) => days[k])); // 보관 중인 일자 + totals(버린 일자)
all.req += d.totals.req; all.pv += d.totals.pv; all.bytesIn += d.totals.bytesIn; all.bytesOut += d.totals.bytesOut; all.bots += d.totals.bots; all.beaconPv += d.totals.bpv; all.beaconBytesOut += d.totals.bBytesOut; all.visitors += d.totals.visitors || 0; all.beaconVisitors += d.totals.bVisitors || 0;
const todayRec = days[today]; const yKey = kstDate(Date.now() - 86400000);
const series = lastN(30).map(([k, r]) => ({ day: k, visitors: visitors(r), pv: r ? r.pv : 0, bytesIn: r ? r.bytesIn : 0, bytesOut: r ? r.bytesOut : 0, beaconPv: r && r.b ? r.b.pv : 0, beaconVisitors: bVisitors(r), beaconBytesOut: r && r.b ? r.b.bytesOut : 0 }));
const merge = (f) => { const m = {}; for (const k of dayKeys.slice(-30)) for (const [key, n] of Object.entries(f(days[k]) || {})) m[key] = (m[key] || 0) + n; return Object.entries(m).sort((a, b) => b[1] - a[1]).slice(0, 8).map(([key, n]) => ({ key, n })); };
const hasLog = all.req > 0; const hasBeacon = all.beaconPv > 0;
// 저장 공간: 사이트 파일(첨부 별도) 과 "DB"(사이트 정보·문의·통계·버전 저장소)
const siteBytes = paths.siteDir ? dirSize(paths.siteDir, new Set(['.git', '.claude', '.refs', 'uploads'])) : 0;
const uploadBytes = paths.siteDir ? dirSize(path.join(paths.siteDir, 'uploads')) : 0;
const db = { site: fileSize(paths.siteJson), inquiries: fileSize(paths.inquiriesJson), stats: fileSize(fileOf(siteId)), git: paths.gitDir ? dirSize(paths.gitDir) : 0 };
db.total = db.site + db.inquiries + db.stats + db.git;
// v1.7.2 (요구 2): 메일 데이터 — 사이트 메일함 파일(data/mail/<site>). 메일 시스템이 꺼져 있으면 null(화면에서 숨김). Arimail 서버 쪽 메일함 용량은 API 가 주지 않아 포함하지 않는다
const mailBytes = paths.mailEnabled && paths.mailDir ? dirSize(paths.mailDir) : null;
// v1.7.2 (요구 2): 달력 기준 월간 합계 — 직전 2개월 + 이번 달(누적 중). 일자 키는 KST 날짜라 월 경계도 KST
const months = [];
for (let i = 2; i >= 0; i--) { const dt = new Date(today + 'T00:00:00Z'); dt.setUTCMonth(dt.getUTCMonth() - i, 1); const key = dt.toISOString().slice(0, 7); const recs = dayKeys.filter((k) => k.startsWith(key)).map((k) => days[k]); months.push({ month: key, days: recs.length, current: key === month, ...agg(recs) }); }
return {
updatedAt: d.updatedAt, sources: { log: hasLog, beacon: hasBeacon },
today: agg([todayRec]), yesterday: agg([days[yKey]]), last7: agg(lastN(7).map((x) => x[1])), last30: agg(lastN(30).map((x) => x[1])), month: agg(dayKeys.filter((k) => k.startsWith(month)).map((k) => days[k])), total: all,
series, series7: series.slice(-7), months,
topPages: merge((r) => r.pages), topRefs: merge((r) => r.refs), devices: merge((r) => r.b && r.b.screens), hosts: merge((r) => r.hosts),
disk: { site: siteBytes, uploads: uploadBytes, total: siteBytes + uploadBytes, web: siteBytes + uploadBytes, mail: mailBytes, limit: null /* 허용량은 아직 없음 */ }, db,
};
}
// ---------- 비컨 삽입 ----------
const BEACON_MARK = 'data-wm-stats';
/**
* 사이트의 모든 HTML 에 통계 비컨 <script> 를 넣는다(이미 있으면 그대로). </body> 앞, 없으면 문서 끝.
* 생성·수정 직후와 배포/원본 반영 직전에 호출해 AI 가 지웠더라도 다시 들어가게 한다.
* @returns {number} 삽입한 파일 수
*/
function ensureBeacon(siteDir, siteId, base) {
if (!base) return 0;
const tag = `<script src="${base.replace(/\/$/, '')}/wm-stats.js" ${BEACON_MARK} data-site="${siteId}" async></script>`;
let n = 0;
const walk = (dir) => {
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
if (ent.name.startsWith('.') || ent.name === 'node_modules' || ent.name === 'uploads') continue;
const p = path.join(dir, ent.name);
if (ent.isDirectory()) { walk(p); continue; }
if (!/\.html?$/i.test(ent.name)) continue;
let html; try { html = fs.readFileSync(p, 'utf8'); } catch (_) { continue; }
if (html.includes(BEACON_MARK)) { if (!html.includes(tag)) { html = html.replace(/<script[^>]*data-wm-stats[^>]*><\/script>\s*/i, tag + '\n'); fs.writeFileSync(p, html); } continue; }
const i = html.search(/<\/body\s*>/i);
html = i >= 0 ? html.slice(0, i) + tag + '\n' + html.slice(i) : html + '\n' + tag + '\n';
try { fs.writeFileSync(p, html); n += 1; } catch (_) {}
}
};
try { walk(siteDir); } catch (_) {}
return n;
}
module.exports = { recordLog, recordBeacon, parseLine, ingestText, collectLocalFile, offsetOf, setOffset, summary, ensureBeacon, remove, flush, kstDate, SITE_ID_RE, STATS_DIR, dirSize };