인기 페이지 통계와 추천 사이트 메타데이터 추가 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

@@ -1,4 +1,9 @@
import { getMethod, getRequestURL, setResponseHeader } from 'h3'
import { getMethod, getRequestHeader, getRequestURL, setResponseHeader } from 'h3'
import { isBotUserAgent } from '../../lib/analytics'
import {
createVisitorHashFromEvent,
recordAnalyticsPageview
} from '../repositories/analytics-repository'
import { getPageBySlug } from '../repositories/content-repository'
/**
@@ -27,6 +32,16 @@ export default defineEventHandler(async (event) => {
return
}
if (method === 'GET' && !isBotUserAgent(String(getRequestHeader(event, 'user-agent') || ''))) {
await recordAnalyticsPageview({
visitorHash: createVisitorHashFromEvent(event),
pageId: page.id,
recordSite: true,
recordView: true,
recordRead: false
})
}
setResponseHeader(event, 'content-type', 'text/html; charset=utf-8')
setResponseHeader(event, 'cache-control', 'no-cache')

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},

View File

@@ -0,0 +1,17 @@
import { requireAdminSession } from '../../../../utils/admin-auth.js'
import { getAnalyticsTopPages } from '../../../../repositories/analytics-repository.js'
/**
* 관리자 인기 페이지 통계 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Array<Object>>} 인기 페이지 목록
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const query = getQuery(event)
const days = Number(query.days) || 30
const limit = Number(query.limit) || 5
return getAnalyticsTopPages({ days, limit })
})

View File

@@ -45,6 +45,8 @@ export default defineEventHandler(async (event) => {
id: row.id,
label: row.label.trim(),
url: row.url.trim(),
descriptionText: row.descriptionText.trim(),
thumbnailUrl: row.thumbnailUrl.trim(),
location: row.location,
sortOrder: row.sortOrder,
isVisible: true,
@@ -70,10 +72,16 @@ export default defineEventHandler(async (event) => {
} catch (err) {
const msg = err?.message != null ? String(err.message) : String(err)
const code = err?.code != null ? String(err.code) : ''
if (msg.includes('parent_id') || msg.includes('is_folder') || code === '42703') {
if (
msg.includes('parent_id') ||
msg.includes('is_folder') ||
msg.includes('description_text') ||
msg.includes('thumbnail_url') ||
code === '42703'
) {
throw createError({
statusCode: 503,
message: 'DB에 navigation_items 확장 컬럼이 없습니다. 프로젝트 루트에서 npm run db:migrate:dev 로 017_navigation_hierarchy.sql을 적용한 뒤 다시 저장하세요.'
message: 'DB에 navigation_items 확장 컬럼이 없습니다. 프로젝트 루트에서 npm run db:migrate:dev 로 최신 마이그레이션을 적용한 뒤 다시 저장하세요.'
})
}
throw err

View File

@@ -4,6 +4,8 @@ export const adminNavigationItemInputSchema = z.object({
id: z.string().uuid(),
label: z.string().trim().min(1),
url: z.string().trim().min(1).regex(/^(\/|https?:\/\/|#)/),
descriptionText: z.string().trim().max(200).optional().default(''),
thumbnailUrl: z.string().trim().max(500).optional().default(''),
location: z.enum(['primary', 'footer', 'recommended']),
sortOrder: z.coerce.number().int().min(0).default(0),
isVisible: z.boolean().default(true),

View File

@@ -2,9 +2,10 @@ import { z } from 'zod'
import {
isBotUserAgent,
isTrackableAnalyticsPath,
normalizePageSlugForAnalytics,
normalizePostSlugForAnalytics
} from '../../lib/analytics.js'
import { getPostBySlug } from '../repositories/content-repository.js'
import { getPageBySlug, getPostBySlug } from '../repositories/content-repository.js'
import {
createSessionHashFromEvent,
recordAnalyticsHeartbeat
@@ -13,6 +14,7 @@ import {
const heartbeatInputSchema = z.object({
path: z.string().trim().min(1).max(500),
postSlug: z.string().trim().max(200).optional().default(''),
pageSlug: z.string().trim().max(200).optional().default(''),
clientSessionId: z.string().trim().min(8).max(120),
durationSeconds: z.number().int().min(0).max(1800),
maxScrollRatio: z.number().min(0).max(1)
@@ -45,7 +47,9 @@ export const handleAnalyticsHeartbeat = async (event) => {
}
const postSlug = normalizePostSlugForAnalytics(body.postSlug)
const pageSlug = normalizePageSlugForAnalytics(body.pageSlug)
let postId = null
let pageId = null
if (postSlug) {
const post = await getPostBySlug(postSlug)
@@ -55,6 +59,14 @@ export const handleAnalyticsHeartbeat = async (event) => {
postId = post.id
}
if (!postId && pageSlug) {
const page = await getPageBySlug(pageSlug)
if (!page) {
return { ok: true }
}
pageId = page.id
}
const sessionHash = createSessionHashFromEvent(event, body.clientSessionId)
await recordAnalyticsHeartbeat({
@@ -63,6 +75,8 @@ export const handleAnalyticsHeartbeat = async (event) => {
path: body.path,
postId,
postSlug,
pageId,
pageSlug,
durationSeconds: body.durationSeconds,
maxScrollRatio: body.maxScrollRatio
})

View File

@@ -2,9 +2,10 @@ import { z } from 'zod'
import {
isBotUserAgent,
isTrackableAnalyticsPath,
normalizePageSlugForAnalytics,
normalizePostSlugForAnalytics
} from '../../lib/analytics.js'
import { getPostBySlug } from '../repositories/content-repository.js'
import { getPageBySlug, getPostBySlug } from '../repositories/content-repository.js'
import {
createVisitorHashFromEvent,
recordAnalyticsPageview
@@ -13,6 +14,7 @@ import {
const pageviewInputSchema = z.object({
path: z.string().trim().min(1).max(500),
postSlug: z.string().trim().max(200).optional().default(''),
pageSlug: z.string().trim().max(200).optional().default(''),
read: z.boolean().optional().default(false)
})
@@ -43,7 +45,9 @@ export const handleAnalyticsPageview = async (event) => {
}
const postSlug = normalizePostSlugForAnalytics(body.postSlug)
const pageSlug = normalizePageSlugForAnalytics(body.pageSlug)
let postId = null
let pageId = null
if (postSlug) {
const post = await getPostBySlug(postSlug)
@@ -53,14 +57,23 @@ export const handleAnalyticsPageview = async (event) => {
postId = post.id
}
if (!postId && pageSlug) {
const page = await getPageBySlug(pageSlug)
if (!page) {
return { ok: true }
}
pageId = page.id
}
const visitorHash = createVisitorHashFromEvent(event)
const isReadEvent = Boolean(body.read)
await recordAnalyticsPageview({
visitorHash,
postId,
pageId,
recordSite: !isReadEvent,
recordView: Boolean(postId) && !isReadEvent,
recordView: (Boolean(postId) || Boolean(pageId)) && !isReadEvent,
recordRead: Boolean(postId) && isReadEvent
})