인기 페이지 통계와 추천 사이트 메타데이터 추가 v1.5.9
This commit is contained in:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
Reference in New Issue
Block a user