인기 페이지 통계와 추천 사이트 메타데이터 추가 v1.5.9

This commit is contained in:
2026-05-27 10:34:07 +09:00
parent d7a3149ea1
commit fd9416c0e4
22 changed files with 596 additions and 94 deletions

View File

@@ -60,6 +60,21 @@ const ensurePostDailyRow = async (sql, day, postId) => {
`
}
/**
* 일별 페이지 통계 행을 보장한다.
* @param {import('postgres').Sql} sql - DB 클라이언트
* @param {string} day - YYYY-MM-DD
* @param {string} pageId - 페이지 ID
* @returns {Promise<void>}
*/
const ensurePageDailyRow = async (sql, day, pageId) => {
await sql`
INSERT INTO page_analytics_daily (day, page_id)
VALUES (${day}, ${pageId})
ON CONFLICT (day, page_id) DO NOTHING
`
}
/**
* 사이트 방문자를 등록하고 신규 방문자면 true를 반환한다.
* @param {import('postgres').Sql} sql - DB 클라이언트
@@ -97,9 +112,28 @@ const registerPostVisitor = async (sql, day, postId, visitorHash) => {
return Boolean(rows[0])
}
/**
* 페이지 방문자를 등록하고 신규 방문자면 true를 반환한다.
* @param {import('postgres').Sql} sql - DB 클라이언트
* @param {string} day - YYYY-MM-DD
* @param {string} pageId - 페이지 ID
* @param {string} visitorHash - 방문자 해시
* @returns {Promise<boolean>} 신규 방문자 여부
*/
const registerPageVisitor = async (sql, day, pageId, visitorHash) => {
const rows = await sql`
INSERT INTO analytics_daily_visitors (day, scope, page_id, visitor_hash)
VALUES (${day}, 'page', ${pageId}, ${visitorHash})
ON CONFLICT DO NOTHING
RETURNING id
`
return Boolean(rows[0])
}
/**
* 페이지뷰·읽음 이벤트를 기록한다.
* @param {{ visitorHash: string, postId?: string | null, recordSite?: boolean, recordView?: boolean, recordRead?: boolean }} input - 기록 입력
* @param {{ visitorHash: string, postId?: string | null, pageId?: string | null, recordSite?: boolean, recordView?: boolean, recordRead?: boolean }} input - 기록 입력
* @returns {Promise<{ ok: true }>}
*/
export const recordAnalyticsPageview = async (input) => {
@@ -112,6 +146,7 @@ export const recordAnalyticsPageview = async (input) => {
const day = getAnalyticsDayKey()
const visitorHash = input.visitorHash
const postId = input.postId || null
const pageId = input.pageId || null
const recordSite = input.recordSite !== false
const recordView = Boolean(input.recordView)
const recordRead = Boolean(input.recordRead)
@@ -131,6 +166,21 @@ export const recordAnalyticsPageview = async (input) => {
}
if (!postId) {
if (pageId && recordView) {
await ensurePageDailyRow(sql, day, pageId)
const isNewPageVisitor = await registerPageVisitor(sql, day, pageId, visitorHash)
await sql`
UPDATE page_analytics_daily
SET
views = views + 1,
visitors = visitors + ${isNewPageVisitor ? 1 : 0}
WHERE day = ${day}
AND page_id = ${pageId}
`
}
await purgeAnalyticsRetention(sql)
return { ok: true }
}
@@ -279,9 +329,57 @@ const incrementPostScrollBuckets = async (sql, day, postId, columns) => {
}
}
/**
* 페이지 스크롤 구간 카운터를 증가시킨다.
* @param {import('postgres').Sql} sql - DB 클라이언트
* @param {string} day - YYYY-MM-DD
* @param {string} pageId - 페이지 ID
* @param {Array<'scroll_25' | 'scroll_50' | 'scroll_75' | 'scroll_100'>} columns - 구간 컬럼
* @returns {Promise<void>}
*/
const incrementPageScrollBuckets = async (sql, day, pageId, columns) => {
if (!columns.length) {
return
}
await ensurePageDailyRow(sql, day, pageId)
for (const column of columns) {
if (column === 'scroll_25') {
await sql`
UPDATE page_analytics_daily
SET scroll_25 = scroll_25 + 1
WHERE day = ${day}
AND page_id = ${pageId}
`
} else if (column === 'scroll_50') {
await sql`
UPDATE page_analytics_daily
SET scroll_50 = scroll_50 + 1
WHERE day = ${day}
AND page_id = ${pageId}
`
} else if (column === 'scroll_75') {
await sql`
UPDATE page_analytics_daily
SET scroll_75 = scroll_75 + 1
WHERE day = ${day}
AND page_id = ${pageId}
`
} else if (column === 'scroll_100') {
await sql`
UPDATE page_analytics_daily
SET scroll_100 = scroll_100 + 1
WHERE day = ${day}
AND page_id = ${pageId}
`
}
}
}
/**
* heartbeat·체류·스크롤·실시간 세션을 기록한다.
* @param {{ event: import('h3').H3Event, sessionHash: string, path: string, postId?: string | null, postSlug?: string, durationSeconds: number, maxScrollRatio: number }} input - 기록 입력
* @param {{ event: import('h3').H3Event, sessionHash: string, path: string, postId?: string | null, postSlug?: string, pageId?: string | null, pageSlug?: string, durationSeconds: number, maxScrollRatio: number }} input - 기록 입력
* @returns {Promise<{ ok: true }>}
*/
export const recordAnalyticsHeartbeat = async (input) => {
@@ -296,6 +394,8 @@ export const recordAnalyticsHeartbeat = async (input) => {
const path = input.path
const postId = input.postId || null
const postSlug = input.postSlug || ''
const pageId = input.pageId || null
const pageSlug = input.pageSlug || ''
const durationSeconds = clampAnalyticsDurationSeconds(input.durationSeconds)
const maxScrollRatio = clampAnalyticsScrollRatio(input.maxScrollRatio)
const memberSession = getMemberSession(input.event)
@@ -320,6 +420,8 @@ export const recordAnalyticsHeartbeat = async (input) => {
path,
post_id,
post_slug,
page_id,
page_slug,
duration_seconds,
max_scroll_ratio
)
@@ -329,6 +431,8 @@ export const recordAnalyticsHeartbeat = async (input) => {
${path},
${postId},
${postSlug},
${pageId},
${pageSlug},
${durationSeconds},
${maxScrollRatio}
)
@@ -338,6 +442,8 @@ export const recordAnalyticsHeartbeat = async (input) => {
path = EXCLUDED.path,
post_id = EXCLUDED.post_id,
post_slug = EXCLUDED.post_slug,
page_id = EXCLUDED.page_id,
page_slug = EXCLUDED.page_slug,
duration_seconds = GREATEST(analytics_active_sessions.duration_seconds, EXCLUDED.duration_seconds),
max_scroll_ratio = GREATEST(analytics_active_sessions.max_scroll_ratio, EXCLUDED.max_scroll_ratio),
last_seen_at = now()
@@ -391,6 +497,33 @@ export const recordAnalyticsHeartbeat = async (input) => {
await incrementPostScrollBuckets(sql, day, postId, scrollBuckets)
}
if (pageId && (durationDelta > 0 || scrollBuckets.length)) {
await ensurePageDailyRow(sql, day, pageId)
if (durationDelta > 0) {
await sql`
UPDATE page_analytics_daily
SET total_engaged_seconds = total_engaged_seconds + ${durationDelta}
WHERE day = ${day}
AND page_id = ${pageId}
`
const wasPageEngaged = previousDuration >= ANALYTICS_ENGAGED_MIN_SECONDS
const isPageEngaged = durationSeconds >= ANALYTICS_ENGAGED_MIN_SECONDS
if (!wasPageEngaged && isPageEngaged) {
await sql`
UPDATE page_analytics_daily
SET engaged_views = engaged_views + 1
WHERE day = ${day}
AND page_id = ${pageId}
`
}
}
await incrementPageScrollBuckets(sql, day, pageId, scrollBuckets)
}
await purgeStaleActiveSessions(sql)
await purgeAnalyticsRetention(sql)
@@ -575,15 +708,18 @@ export const getAnalyticsActiveSessions = async (options = {}) => {
analytics_active_sessions.session_hash,
analytics_active_sessions.path,
analytics_active_sessions.post_slug,
analytics_active_sessions.page_slug,
analytics_active_sessions.duration_seconds,
analytics_active_sessions.max_scroll_ratio,
analytics_active_sessions.last_seen_at,
posts.title AS post_title,
pages.title AS page_title,
users.id AS user_id,
users.username,
users.avatar_url
FROM analytics_active_sessions
LEFT JOIN posts ON posts.id = analytics_active_sessions.post_id
LEFT JOIN pages ON pages.id = analytics_active_sessions.page_id
LEFT JOIN users ON users.id = analytics_active_sessions.user_id
WHERE analytics_active_sessions.last_seen_at >= now() - (${ANALYTICS_ACTIVE_SESSION_TTL_SECONDS} * interval '1 second')
ORDER BY analytics_active_sessions.last_seen_at DESC
@@ -594,7 +730,9 @@ export const getAnalyticsActiveSessions = async (options = {}) => {
sessionHash: row.session_hash,
path: row.path,
postSlug: row.post_slug || '',
pageSlug: row.page_slug || '',
postTitle: row.post_title || '',
pageTitle: row.page_title || '',
durationSeconds: Number(row.duration_seconds || 0),
maxScrollRatio: Number(row.max_scroll_ratio || 0),
lastSeenAt: row.last_seen_at ? new Date(row.last_seen_at).toISOString() : null,
@@ -668,3 +806,61 @@ export const getAnalyticsTopPosts = async (options = {}) => {
}
})
}
/**
* 인기 페이지 통계를 조회한다.
* @param {{ days?: number, limit?: number }} [options] - 조회 옵션
* @returns {Promise<Array<Object>>} 인기 페이지 목록
*/
export const getAnalyticsTopPages = async (options = {}) => {
const sql = getPostgresClient()
const days = Math.min(Math.max(Number(options.days) || 30, 1), ANALYTICS_CHART_MAX_DAYS)
const limit = Math.min(Math.max(Number(options.limit) || 5, 1), 20)
if (!sql) {
return []
}
const today = getAnalyticsDayKey()
const rangeStartDay = getAnalyticsDayBefore(today, days - 1)
await purgeAnalyticsRetention(sql)
const rows = await sql`
SELECT
pages.id,
pages.title,
pages.slug,
COALESCE(SUM(page_analytics_daily.views), 0)::int AS views,
COALESCE(SUM(page_analytics_daily.visitors), 0)::int AS visitors,
COALESCE(SUM(page_analytics_daily.engaged_views), 0)::int AS engaged_views,
COALESCE(SUM(page_analytics_daily.total_engaged_seconds), 0)::int AS total_engaged_seconds,
COALESCE(SUM(page_analytics_daily.scroll_50), 0)::int AS scroll_50,
COALESCE(SUM(page_analytics_daily.scroll_75), 0)::int AS scroll_75,
COALESCE(SUM(page_analytics_daily.scroll_100), 0)::int AS scroll_100
FROM page_analytics_daily
INNER JOIN pages ON pages.id = page_analytics_daily.page_id
WHERE page_analytics_daily.day >= ${rangeStartDay}::date
GROUP BY pages.id, pages.title, pages.slug
ORDER BY views DESC, pages.updated_at DESC NULLS LAST
LIMIT ${limit}
`
return rows.map((row) => {
const engagedViews = Number(row.engaged_views || 0)
const totalEngagedSeconds = Number(row.total_engaged_seconds || 0)
return {
id: row.id,
title: row.title,
slug: row.slug,
views: Number(row.views || 0),
visitors: Number(row.visitors || 0),
avgEngagedSeconds: engagedViews > 0
? Math.round(totalEngagedSeconds / engagedViews)
: 0,
scroll50: Number(row.scroll_50 || 0),
scroll75: Number(row.scroll_75 || 0),
scroll100: Number(row.scroll_100 || 0)
}
})
}

View File

@@ -122,6 +122,8 @@ const mapNavigationItemRow = (row) => ({
id: row.id,
label: row.label,
url: row.url,
descriptionText: row.description_text || '',
thumbnailUrl: row.thumbnail_url || '',
location: row.location,
sortOrder: row.sort_order,
isVisible: row.is_visible,
@@ -1046,6 +1048,8 @@ export const getPublicNavigation = async () => {
id: item.id,
label: item.label,
url: item.url,
descriptionText: item.descriptionText,
thumbnailUrl: item.thumbnailUrl,
isVisible: item.isVisible
}))
}
@@ -1075,6 +1079,8 @@ export const updateNavigationItems = async (items) => {
id,
label,
url,
description_text,
thumbnail_url,
location,
sort_order,
is_visible,
@@ -1085,6 +1091,8 @@ export const updateNavigationItems = async (items) => {
${item.id},
${item.label},
${item.url},
${item.descriptionText || ''},
${item.thumbnailUrl || ''},
${item.location},
${item.sortOrder},
${item.isVisible},