diff --git a/db/migrations/045_analytics_traffic_sources.sql b/db/migrations/045_analytics_traffic_sources.sql new file mode 100644 index 0000000..c9ca40f --- /dev/null +++ b/db/migrations/045_analytics_traffic_sources.sql @@ -0,0 +1,42 @@ +CREATE TABLE IF NOT EXISTS analytics_traffic_daily ( + day DATE NOT NULL, + source_group TEXT NOT NULL, + source_name TEXT NOT NULL, + device_type TEXT NOT NULL, + os_name TEXT NOT NULL, + keyword TEXT NOT NULL DEFAULT '', + page_views INTEGER NOT NULL DEFAULT 0, + visitors INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (day, source_group, source_name, device_type, os_name, keyword) +); + +CREATE INDEX IF NOT EXISTS analytics_traffic_daily_day_idx + ON analytics_traffic_daily (day DESC); + +CREATE INDEX IF NOT EXISTS analytics_traffic_daily_source_idx + ON analytics_traffic_daily (source_group, source_name); + +ALTER TABLE analytics_daily_visitors + ADD COLUMN IF NOT EXISTS source_group TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS source_name TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS device_type TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS os_name TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS keyword TEXT NOT NULL DEFAULT ''; + +ALTER TABLE analytics_daily_visitors + DROP CONSTRAINT IF EXISTS analytics_daily_visitors_scope_check; + +ALTER TABLE analytics_daily_visitors + ADD CONSTRAINT analytics_daily_visitors_scope_check CHECK (scope IN ('site', 'post', 'page', 'traffic')); + +CREATE UNIQUE INDEX IF NOT EXISTS analytics_daily_visitors_traffic_uidx + ON analytics_daily_visitors ( + day, + visitor_hash, + source_group, + source_name, + device_type, + os_name, + keyword + ) + WHERE scope = 'traffic'; diff --git a/docs/changelog.md b/docs/changelog.md index f5974ab..2a5a70a 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,11 @@ # 업데이트 요약 +## v1.5.35 + +- 관리자 대시보드에 방문자 유입 정보, 디바이스 통계, 유입 키워드 영역을 추가했다. +- 인기 게시물 목록에서 월간 조회수와 작성일을 함께 확인할 수 있게 했다. +- 페이지뷰 통계가 유입원과 디바이스를 일별 축약 집계하도록 개선했다. + ## v1.5.34 - 공개 404/오류 페이지를 추가했다. diff --git a/docs/deploy.md b/docs/deploy.md index b369fa2..f568d76 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -1,6 +1,6 @@ # 배포 가이드 -> 로컬 기준 v1.5.34에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다. +> 로컬 기준 v1.5.35에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다. ## 빌드 유형 @@ -68,6 +68,12 @@ docker exec sori-studio-db pg_isready -U sori_studio -d sori_studio docker exec sori-studio-db psql -U sori_studio -d sori_studio -c 'SELECT count(*) AS posts_count FROM posts;' ``` +### v1.5.35 마이그레이션 + +- `045_analytics_traffic_sources.sql`: 방문자 유입원·디바이스·검색 키워드 일별 축약 집계 테이블과 중복 방문 제거용 컬럼을 추가한다. +- 적용 이후부터 수집되는 페이지뷰에 대해서만 유입 정보가 쌓인다. 과거 방문 데이터는 소급 집계하지 않는다. +- 검색 키워드는 검색엔진이 referrer query를 전달한 경우에만 표시된다. + ### v1.5.34 마이그레이션 - `044_site_settings_custom_code.sql`: `site_settings`에 `ads_txt`, `custom_head_code`, `custom_footer_code` 컬럼을 추가한다. @@ -261,6 +267,7 @@ docker compose --env-file .env.production up -d --build ### 통계 데이터 보관 정책 - `site_analytics_daily`, `post_analytics_daily`: 사이트 전체 방문자와 게시물별 조회수의 누적 원본이므로 자동 삭제하지 않는다. +- `analytics_traffic_daily`: 유입원·디바이스·키워드 축약 집계 원본이므로 자동 삭제하지 않는다. - `analytics_daily_visitors`: 일별 중복 방문 제거용 해시만 담으므로 32일 초과 행은 통계 수집·관리자 조회 흐름에서 주기적으로 삭제한다. - `analytics_active_sessions`: 현재 접속자 목록용 임시 데이터이며 90초 초과 행은 조회·수집 시 삭제한다. - 관리자 대시보드 차트는 최대 365일 범위를 조회하며, 차트 범위를 넘는 집계도 누적 통계 원본으로 보관한다. diff --git a/docs/history.md b/docs/history.md index 6baba1d..527a85c 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-06-02 v1.5.35 — 유입 분석은 축약 집계로 시작한다 + +관리자 대시보드는 방문자가 어디서 들어왔는지, 어떤 기기로 보는지, 검색 키워드가 있는지 정도를 빠르게 파악할 수 있어야 한다. 다만 원문 referrer나 IP를 오래 저장하면 운영 부담과 개인정보 리스크가 커지므로, 페이지뷰 요청 시 서버가 즉시 검색·SNS·직접·기타, 디바이스·OS, 키워드로 축약해 일별 집계만 남긴다. 검색 키워드는 검색엔진이 referrer query를 전달한 경우에만 수집되며, 숨겨진 키워드는 소급하거나 추정하지 않는다. + ## 2026-06-02 v1.5.34 — 사이트 검증 코드는 설정으로 관리한다 AdSense의 `ads.txt`나 헤더 검증 스크립트는 테마 파일을 직접 고치기보다 운영 설정에서 바꾸는 편이 안전하다. 그래서 사이트 설정에 ads.txt, 헤더 코드, 푸터 코드를 별도 카드로 두고, 공개 HTML 응답과 루트 `/ads.txt`에서만 반영한다. 관리자 화면과 API 응답에는 삽입하지 않아 관리 도구가 외부 광고·검증 스크립트에 영향을 받지 않게 했다. gethomepage 연동은 아직 표시 항목이 확정되지 않았으므로 기존 통계 집계에서 바로 만들 수 있는 오늘 방문자·페이지뷰·현재 접속자·평균 체류시간을 반환하는 커스텀 API 틀로 시작한다. diff --git a/docs/map.md b/docs/map.md index acca2d6..b919e3c 100644 --- a/docs/map.md +++ b/docs/map.md @@ -35,6 +35,7 @@ | lib/markdown-slash-commands.js | 관리자 Markdown-first 에디터 슬래시 명령 목록과 삽입 줄 정의 | | lib/analytics-shared.js | 통계 추적 경로 필터·체류/스크롤 상수(클라이언트·서버 공용) | | lib/analytics.js | 서버 전용 visitor/session hash(`node:crypto`) | +| lib/analytics-traffic.js | referrer·User-Agent 기반 유입원·디바이스·검색 키워드 축약 분류 | ## Nuxt 모듈 @@ -128,7 +129,7 @@ | 파일 | 화면 | |------|------| -| pages/admin/index.vue | 대시보드(상단 요약: 현재 접속자·오늘 접속자·게시물 수, 기간 선택형 방문자수·평균 체류·50% 스크롤 차트, 접속자 목록, 인기 게시물·인기 페이지 참여 지표) | +| pages/admin/index.vue | 대시보드(상단 요약: 현재 접속자·오늘 접속자·게시물 수, 기간 선택형 방문자수·평균 체류·50% 스크롤 차트, 방문자 유입 정보·디바이스·유입 키워드, 접속자 목록, 인기 게시물 월간 조회수·작성일, 인기 페이지 참여 지표) | | pages/admin/login.vue | 관리자 로그인, 일반 로그인과 같은 다크 인증 스타일·우측 배치 및 내부 오른쪽 정렬 폼, 이메일·비밀번호 모두 입력 시에만 제출 버튼 활성 | | pages/admin/posts/index.vue | 글 목록, 헤더 우측에 검색·필터(상태·태그·추천·정렬)와 «새 글»을 한 줄 배치, 예약/초안/발행/멤버십/비공개 텍스트 상태, 추천 표시와 제목 사이 대표 이미지 썸네일, 제목 옆 댓글 수, 첫 번째 태그 색상 대표 배지, 화면 기준 행 more vert 메뉴(추천·삭제) | | pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 초안 `POST` 디바운스 자동 저장·이탈 직전 플러시, 저장 토스트 | @@ -267,7 +268,7 @@ | server/repositories/post-import-repository.js | 게시물 Export ZIP Import 저장소(frontmatter·자산 재매핑·게시물 생성) | | server/repositories/member-repository.js | 회원 조회/생성 저장소 | | server/repositories/comment-repository.js | 댓글 조회/생성 저장소 | -| server/repositories/analytics-repository.js | 방문·게시물 통계 집계·관리자 요약 조회·방문자 해시 보관 정리 | +| server/repositories/analytics-repository.js | 방문·게시물·유입 통계 집계·관리자 요약 조회·방문자 해시 보관 정리 | | server/utils/analytics-pageview-input.js | `POST /api/analytics/pageview` 검증·기록 | | server/utils/zip-writer.js | 게시물 Export ZIP 생성 유틸리티 | | server/utils/zip-reader.js | 게시물 Import ZIP 읽기 유틸리티 | @@ -278,6 +279,7 @@ | server/routes/admin/api/analytics/posts.get.js | 관리자 인기 게시물 API | | server/routes/admin/api/analytics/pages.get.js | 관리자 인기 페이지 API | | server/routes/admin/api/analytics/realtime.get.js | 관리자 실시간 접속자 API | +| server/routes/admin/api/analytics/traffic.get.js | 관리자 유입원·디바이스·키워드 통계 API | | plugins/site-analytics.client.js | 공개 라우트 pageview·heartbeat·read 클라이언트 전송 | ## 데이터베이스 @@ -318,6 +320,7 @@ | db/migrations/042_post_export_date_range.sql | 게시물 Export 날짜 범위 컬럼 추가 | | db/migrations/043_post_export_size_and_error_detail.sql | 게시물 Export 목표 용량·실패 상세 로그 컬럼 추가 | | db/migrations/044_site_settings_custom_code.sql | 사이트 설정 ads.txt·공통 헤더 코드·공통 푸터 코드 컬럼 추가 | +| db/migrations/045_analytics_traffic_sources.sql | 방문자 유입원·디바이스·키워드 일별 집계 테이블 추가 | ## 설정/배포 diff --git a/docs/spec.md b/docs/spec.md index f98d6ff..ea0861f 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -447,17 +447,22 @@ components/content/ | site_analytics_daily | day, page_views, visitors, engaged_views, total_engaged_seconds | 사이트 일별 페이지뷰·방문자·체류 집계 | | post_analytics_daily | day, post_id, views, reads, visitors, engaged_views, total_engaged_seconds, scroll_25~100 | 게시물 일별 조회·읽음·스크롤 구간 | | page_analytics_daily | day, page_id, views, visitors, engaged_views, total_engaged_seconds, scroll_25~100 | 페이지 일별 조회·방문자·스크롤 구간 | -| analytics_daily_visitors | day, scope(`site`/`post`/`page`), post_id?, page_id?, visitor_hash | 일별 방문자 해시 등록(중복 방문 제거용) | +| analytics_traffic_daily | day, source_group, source_name, device_type, os_name, keyword, page_views, visitors | 유입원·디바이스·키워드 일별 축약 집계 | +| analytics_daily_visitors | day, scope(`site`/`post`/`page`/`traffic`), post_id?, page_id?, visitor_hash, source_group?, source_name?, device_type?, os_name?, keyword? | 일별 방문자 해시 등록(중복 방문 제거용) | | analytics_active_sessions | session_hash, user_id?, path, post_id?, post_slug, page_id?, page_slug, duration_seconds, max_scroll_ratio, last_seen_at | 실시간 접속 세션(TTL 90초) | - 추적 대상: 공개 경로만. `/admin`, `/signin`, `/signup`, `/forgot-password`, `/settings`는 제외. - 봇 User-Agent는 서버에서 무시. +- 페이지뷰 수집은 탭 최초 referrer와 현재 URL을 서버로 보내며, 서버는 원문 referrer를 저장하지 않고 검색·SNS·직접·기타, 디바이스·OS, 검색 키워드로 축약해 `analytics_traffic_daily`에 집계한다. +- 검색 유입은 네이버·다음·구글·줌·빙을 우선 분류하고, SNS 유입은 카카오톡·페이스북·인스타그램·트위터/X·유튜브를 우선 분류한다. 검색 키워드는 referrer query에 남아 있는 경우만 표시한다. - 게시물 `reads`는 클라이언트에서 15초 이상 체류·50% 이상 스크롤 후 별도 전송. - `POST /api/analytics/heartbeat`는 20초 간격으로 체류시간·스크롤·현재 경로를 전송한다. 로그인 사용자는 서버 세션으로 `user_id`를 연결한다. - 관리자 대시보드는 `GET /admin/api/analytics/realtime`으로 현재 접속자 목록(닉네임·아바타·게시물 제목·접속 유지시간)을 조회한다. - 관리자 대시보드 **통계 추이**는 `trends` 데이터를 3개 막대 차트(방문자수·평균 체류시간·50% 스크롤 도달)로 표시한다. 7일은 일자별로 표시하고, 30일 이상은 선택 기간에 따라 7일·14일·30일 단위로 묶어 카드 폭을 넘지 않게 한다. 막대 hover/focus 시 기간과 정확한 값을 툴팁으로 표시하며, 표(table)나 외부 차트 라이브러리는 사용하지 않는다. +- 관리자 대시보드 **방문자 유입 정보**는 선택 기간 기준 검색·SNS·기타(직접 포함) 유입원, 디바이스/OS, 유입 키워드를 표시한다. +- 관리자 대시보드 **인기 게시물**은 선택 기간 조회수와 함께 최근 30일 월간 조회수, 작성일을 표시한다. - 관리자 차트는 최대 365일 범위를 조회한다. -- `site_analytics_daily`, `post_analytics_daily`는 사이트 전체 방문자와 게시물별 조회수 누적 원본이므로 자동 삭제하지 않는다. +- `site_analytics_daily`, `post_analytics_daily`, `analytics_traffic_daily`는 사이트 전체 방문자와 게시물별 조회수, 유입 통계 누적 원본이므로 자동 삭제하지 않는다. - `analytics_daily_visitors`는 일별 중복 방문 제거용이며, 수집·조회 흐름에서 32일보다 오래된 행을 주기적으로 삭제한다. - `analytics_active_sessions`는 현재 접속자 목록용이며, 90초보다 오래된 행을 삭제한다. @@ -490,7 +495,7 @@ components/content/ - `GET /api/search?q=` - 통합 검색(태그 `name`·`slug`, 게시물 `title`·`excerpt`·`content` 부분 일치, 각 최대 12건, 발행 게시물만) - `GET /api/site-settings` - 공개 사이트 설정(어나운스 바·홈 커버 등 포함) - `GET /api/homepage-widget` - gethomepage customapi용 사이트 요약. `title`, `updatedAt`, `todayVisitors`, `todayPageViews`, `onlineNow`, `loggedInNow`, `avgEngagedSeconds`, `items[]`를 반환한다. -- `POST /api/analytics/pageview` - 공개 방문·게시물·페이지 조회/읽음 집계. 본문: `path`(필수), `postSlug`(게시물일 때), `pageSlug`(페이지일 때), `read`(읽음 이벤트). 발행된 게시물과 공개 페이지만 개별 집계한다. 응답 `{ ok: true }`. HTML 문서 모드 페이지는 Nuxt 클라이언트 플러그인을 거치지 않으므로 서버 미들웨어가 GET 요청 시 페이지 조회를 직접 기록한다. +- `POST /api/analytics/pageview` - 공개 방문·게시물·페이지 조회/읽음 집계. 본문: `path`(필수), `postSlug`(게시물일 때), `pageSlug`(페이지일 때), `referrer`, `currentUrl`, `read`(읽음 이벤트). 발행된 게시물과 공개 페이지만 개별 집계한다. 응답 `{ ok: true }`. HTML 문서 모드 페이지는 Nuxt 클라이언트 플러그인을 거치지 않으므로 서버 미들웨어가 GET 요청 시 페이지 조회를 직접 기록한다. - `POST /api/analytics/heartbeat` - 실시간 세션·체류·스크롤 집계. 본문: `path`, `postSlug`, `pageSlug`, `clientSessionId`, `durationSeconds`(최대 1800), `maxScrollRatio`(0~1). 로그인 시 서버가 회원 세션으로 사용자 연결. - `GET /api/navigation` - 공개 네비게이션(`primary`는 트리·`footer`·`recommended`는 평면, 상세는 위 메뉴/네비게이션 절) - `GET /ads.txt` - 사이트 설정의 ads.txt 본문을 `text/plain`으로 반환한다. 값이 없으면 빈 본문을 반환한다. @@ -525,9 +530,10 @@ components/content/ - `POST /admin/api/auth/logout` - 로그아웃 - `GET /admin/api/auth/me` - 현재 관리자 세션 조회 - `GET /admin/api/analytics/summary?days=30` - 통계 요약(오늘/7일 방문, 30일 조회, 현재 접속자, 평균 체류, 50% 스크롤 도달, 일자별 `trends`). `days`는 대시보드에서 7/30/90/180/365로 전환한다. -- `GET /admin/api/analytics/posts?days=30&limit=5` - 기간 내 인기 게시물(조회·읽음·평균 체류·스크롤 구간) +- `GET /admin/api/analytics/posts?days=30&limit=5` - 기간 내 인기 게시물(조회·최근 30일 월간 조회·작성일·읽음·평균 체류·스크롤 구간) - `GET /admin/api/analytics/pages?days=30&limit=5` - 기간 내 인기 페이지(조회·방문자·평균 체류·스크롤 구간) - `GET /admin/api/analytics/realtime?limit=20` - 현재 접속자 요약·목록(로그인 사용자 닉네임·아바타 포함) +- `GET /admin/api/analytics/traffic?days=30` - 기간 내 유입원·디바이스·유입 키워드 통계 - `GET /admin/api/posts` - 글 목록 - `POST /admin/api/posts` - 글 작성 - `GET /admin/api/posts/:id` - 글 상세 diff --git a/docs/update.md b/docs/update.md index 35c7a11..1321c32 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,13 @@ # 업데이트 이력 +## v1.5.35 + +- 관리자 대시보드: 검색·SNS·직접·기타 유입 정보 카드 추가. +- 관리자 대시보드: 디바이스·OS별 방문 집계와 유입 키워드 목록 추가. +- 관리자 대시보드: 인기 게시물에 월간 조회수와 작성일 표시 추가. +- Analytics: 페이지뷰 수집 시 referrer·현재 URL을 받아 유입원과 키워드를 집계하도록 추가. +- DB: `analytics_traffic_daily`와 유입 중복 방문자 집계 컬럼 추가. + ## v1.5.34 - 공개 화면: Nuxt 전역 404/오류 페이지 추가. diff --git a/lib/analytics-traffic.js b/lib/analytics-traffic.js new file mode 100644 index 0000000..0e366e1 --- /dev/null +++ b/lib/analytics-traffic.js @@ -0,0 +1,152 @@ +/** @type {number} 통계 키워드 최대 길이 */ +const MAX_KEYWORD_LENGTH = 80 + +/** @type {Array<{ name: string, hostPattern: RegExp, keywordParams: string[] }>} 검색 유입 규칙 */ +const SEARCH_SOURCE_RULES = [ + { name: '구글', hostPattern: /(^|\.)google\./i, keywordParams: ['q'] }, + { name: '네이버', hostPattern: /(^|\.)naver\.com$/i, keywordParams: ['query', 'q'] }, + { name: '다음', hostPattern: /(^|\.)daum\.net$|(^|\.)kakao\.com$/i, keywordParams: ['q', 'query'] }, + { name: '빙', hostPattern: /(^|\.)bing\.com$/i, keywordParams: ['q'] }, + { name: '줌', hostPattern: /(^|\.)zum\.com$/i, keywordParams: ['query', 'q'] } +] + +/** @type {Array<{ name: string, hostPattern: RegExp }>} SNS 유입 규칙 */ +const SOCIAL_SOURCE_RULES = [ + { name: '카카오톡', hostPattern: /(^|\.)kakao\.com$|(^|\.)kakaocdn\.net$/i }, + { name: '페이스북', hostPattern: /(^|\.)facebook\.com$|(^|\.)fb\.com$|(^|\.)fb\.me$/i }, + { name: '인스타그램', hostPattern: /(^|\.)instagram\.com$/i }, + { name: '트위터', hostPattern: /(^|\.)twitter\.com$|(^|\.)x\.com$|(^|\.)t\.co$/i }, + { name: '유튜브', hostPattern: /(^|\.)youtube\.com$|(^|\.)youtu\.be$/i } +] + +/** + * 문자열 값을 안전한 통계 라벨로 정리한다. + * @param {string} value - 원본 문자열 + * @param {number} maxLength - 최대 길이 + * @returns {string} 정리된 문자열 + */ +const normalizeAnalyticsText = (value, maxLength = 120) => { + return String(value || '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, maxLength) +} + +/** + * URL 문자열을 URL 객체로 변환한다. + * @param {string} value - URL 문자열 + * @returns {URL | null} URL 객체 + */ +const parseAnalyticsUrl = (value) => { + const rawValue = String(value || '').trim() + if (!rawValue) { + return null + } + + try { + return new URL(rawValue) + } catch { + return null + } +} + +/** + * 검색 유입 키워드를 추출한다. + * @param {URL} url - referrer URL + * @param {string[]} params - 키워드 후보 파라미터 + * @returns {string} 키워드 + */ +const extractSearchKeyword = (url, params) => { + const keywordParams = [...params, 'keyword', 'search'] + for (const param of keywordParams) { + const value = normalizeAnalyticsText(url.searchParams.get(param), MAX_KEYWORD_LENGTH) + if (value) { + return value + } + } + + return '' +} + +/** + * 유입 URL을 검색·SNS·직접·기타로 분류한다. + * @param {{ referrer?: string, currentUrl?: string }} input - 분류 입력 + * @returns {{ sourceGroup: string, sourceName: string, keyword: string }} 유입 분류 + */ +export const classifyAnalyticsTrafficSource = (input = {}) => { + const referrerUrl = parseAnalyticsUrl(input.referrer) + const currentUrl = parseAnalyticsUrl(input.currentUrl) + + if (!referrerUrl) { + return { + sourceGroup: 'direct', + sourceName: '직접 유입', + keyword: '' + } + } + + if (currentUrl && referrerUrl.hostname === currentUrl.hostname) { + return { + sourceGroup: 'direct', + sourceName: '직접 유입', + keyword: '' + } + } + + const host = referrerUrl.hostname.replace(/^www\./i, '') + const searchRule = SEARCH_SOURCE_RULES.find((rule) => rule.hostPattern.test(host)) + + if (searchRule) { + return { + sourceGroup: 'search', + sourceName: searchRule.name, + keyword: extractSearchKeyword(referrerUrl, searchRule.keywordParams) + } + } + + const socialRule = SOCIAL_SOURCE_RULES.find((rule) => rule.hostPattern.test(host)) + + if (socialRule) { + return { + sourceGroup: 'sns', + sourceName: socialRule.name, + keyword: '' + } + } + + return { + sourceGroup: 'other', + sourceName: '기타 유입', + keyword: '' + } +} + +/** + * User-Agent에서 디바이스와 OS를 분류한다. + * @param {string} userAgent - User-Agent + * @returns {{ deviceType: string, osName: string }} 디바이스 분류 + */ +export const classifyAnalyticsDevice = (userAgent) => { + const value = String(userAgent || '') + const lowerValue = value.toLowerCase() + const isMobile = /mobile|iphone|ipod|android.*mobile|windows phone/i.test(value) + const isTablet = /ipad|tablet|android(?!.*mobile)/i.test(value) + let osName = '기타' + + if (/iphone|ipad|ipod/i.test(value)) { + osName = 'iOS' + } else if (/android/i.test(value)) { + osName = 'Android' + } else if (/windows/i.test(value)) { + osName = 'Windows' + } else if (/mac os|macintosh|mac_powerpc/i.test(lowerValue)) { + osName = 'macOS' + } else if (/linux/i.test(value)) { + osName = 'Linux' + } + + return { + deviceType: isMobile || isTablet ? '모바일' : 'PC', + osName + } +} diff --git a/package-lock.json b/package-lock.json index a4dfcb0..42722c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.5.34", + "version": "1.5.35", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.5.34", + "version": "1.5.35", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index e718456..2d1fc9b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.5.34", + "version": "1.5.35", "private": true, "type": "module", "imports": { diff --git a/pages/admin/index.vue b/pages/admin/index.vue index 2a34fda..f079a2d 100644 --- a/pages/admin/index.vue +++ b/pages/admin/index.vue @@ -48,6 +48,16 @@ const { data: topPages, refresh: refreshTopPages } = await useFetch('/admin/api/ default: () => [] }) +const { data: trafficSummary, refresh: refreshTrafficSummary } = await useFetch('/admin/api/analytics/traffic', { + query: analyticsQuery, + default: () => ({ + sources: [], + devices: [], + keywords: [], + days: 30 + }) +}) + const { data: realtime, refresh: refreshRealtime } = await useFetch('/admin/api/analytics/realtime', { query: { limit: 20 }, default: () => ({ @@ -68,6 +78,34 @@ const analyticsRangeLabel = computed(() => { const trendRows = computed(() => analyticsSummary.value.trends || []) const trendStartDay = computed(() => trendRows.value[0]?.day || '') const trendEndDay = computed(() => trendRows.value[trendRows.value.length - 1]?.day || '') +const trafficSources = computed(() => trafficSummary.value.sources || []) +const trafficDevices = computed(() => trafficSummary.value.devices || []) +const trafficKeywords = computed(() => trafficSummary.value.keywords || []) +const totalTrafficPageViews = computed(() => { + return trafficSources.value.reduce((sum, row) => sum + Number(row.pageViews || 0), 0) +}) + +/** + * 관리자 날짜를 YYYY.MM.DD로 표시한다. + * @param {string | null} value - 날짜 값 + * @returns {string} 표시 문자열 + */ +const formatAdminDate = (value) => { + if (!value) { + return '-' + } + + const date = new Date(value) + if (Number.isNaN(date.getTime())) { + return '-' + } + + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + + return `${year}.${month}.${day}` +} /** * 초 단위 체류시간을 읽기 쉬운 문자열로 변환한다. @@ -123,6 +161,50 @@ const formatTrendValue = (key, value) => { return `${Number(value || 0)}회` } +/** + * 유입 그룹 표시명을 반환한다. + * @param {string} sourceGroup - 유입 그룹 + * @returns {string} 표시명 + */ +const getTrafficGroupLabel = (sourceGroup) => { + if (sourceGroup === 'search') { + return '검색' + } + + if (sourceGroup === 'sns') { + return 'SNS' + } + + return '기타' +} + +/** + * 유입 그룹별 행을 반환한다. + * @param {string} sourceGroup - 유입 그룹 + * @returns {Array} 유입 행 + */ +const getTrafficRowsByGroup = (sourceGroup) => { + if (sourceGroup === 'other') { + return trafficSources.value.filter((row) => row.sourceGroup === 'direct' || row.sourceGroup === 'other') + } + + return trafficSources.value.filter((row) => row.sourceGroup === sourceGroup) +} + +/** + * 유입 비율을 표시한다. + * @param {number} value - 값 + * @returns {string} 비율 + */ +const formatTrafficPercent = (value) => { + const total = totalTrafficPageViews.value + if (!total) { + return '0%' + } + + return `${Math.round((Number(value || 0) / total) * 100)}%` +} + /** * 추세 막대 툴팁 문구를 반환한다. * @param {{ key: 'visitors' | 'avgEngagedSeconds' | 'scroll50Reach', title: string }} metric - 지표 정보 @@ -273,6 +355,7 @@ onMounted(() => { refreshSummary() refreshTopPosts() refreshTopPages() + refreshTrafficSummary() refreshRealtime() }, 30000) }) @@ -287,6 +370,7 @@ watch(selectedAnalyticsDays, () => { refreshSummary() refreshTopPosts() refreshTopPages() + refreshTrafficSummary() }) @@ -393,6 +477,143 @@ watch(selectedAnalyticsDays, () => { +
+
+
+

+ 방문자 유입 정보 ({{ analyticsRangeLabel }}) +

+

+ 검색·SNS·직접 유입과 디바이스 기준 집계 +

+
+

+ 페이지뷰 {{ totalTrafficPageViews }}회 +

+
+ +
+
+
+

+ {{ getTrafficGroupLabel(group) }} +

+
    +
  • +
    + {{ row.sourceName }} + {{ formatTrafficPercent(row.pageViews) }} +
    +
    +
    +
    +

    + 조회 {{ row.pageViews }} · 방문자 {{ row.visitors }} +

    +
  • +
+

+ 아직 집계된 데이터가 없습니다. +

+
+
+ +
+

+ 디바이스 +

+
    +
  • +
    +

    + {{ row.deviceType }} +

    +

    + {{ row.osName }} +

    +
    +

    + {{ row.pageViews }}회 +
    + {{ row.visitors }}명 +

    +
  • +
+

+ 아직 집계된 디바이스 데이터가 없습니다. +

+
+
+ +
+
+

+ 유입 키워드 +

+

+ 검색엔진이 키워드를 전달한 경우만 표시 +

+
+
    +
  • +
    +

    + {{ row.keyword }} +

    +

    + {{ row.sourceName }} +

    +
    +

    + {{ row.pageViews }}회 +
    + {{ row.visitors }}명 +

    +
  • +
+

+ 아직 수집된 유입 키워드가 없습니다. +

+
+
+

@@ -489,6 +710,22 @@ watch(selectedAnalyticsDays, () => { {{ item.reads }}

+
+
+ 월간 +
+
+ {{ item.monthlyViews }} +
+
+
+
+ 작성일 +
+
+ {{ formatAdminDate(item.createdAt) }} +
+
체류 diff --git a/plugins/site-analytics.client.js b/plugins/site-analytics.client.js index 74509e2..b7689f0 100644 --- a/plugins/site-analytics.client.js +++ b/plugins/site-analytics.client.js @@ -6,6 +6,9 @@ import { /** @type {string} 탭 단위 클라이언트 세션 storage 키 */ const CLIENT_SESSION_STORAGE_KEY = 'sori_analytics_client_session' +/** @type {string} 최초 유입 referrer storage 키 */ +const INITIAL_REFERRER_STORAGE_KEY = 'sori_analytics_initial_referrer' + /** @type {number} 읽음 판정 최소 체류 시간(ms) */ const READ_MIN_DURATION_MS = 15000 @@ -73,6 +76,25 @@ const getClientSessionId = () => { } } +/** + * 탭 최초 유입 referrer를 반환한다. + * @returns {string} referrer + */ +const getInitialReferrer = () => { + try { + const existing = sessionStorage.getItem(INITIAL_REFERRER_STORAGE_KEY) + if (existing !== null) { + return existing + } + + const created = String(document.referrer || '').trim() + sessionStorage.setItem(INITIAL_REFERRER_STORAGE_KEY, created) + return created + } catch { + return String(document.referrer || '').trim() + } +} + /** * 게시물 상세 경로에서 slug를 추출한다. * @param {import('vue-router').RouteLocationNormalizedLoaded} route - 현재 라우트 @@ -209,6 +231,8 @@ const sendPageviewEvent = (payload) => { path: payload.path, postSlug: payload.postSlug || '', pageSlug: payload.pageSlug || '', + referrer: getInitialReferrer(), + currentUrl: window.location.href, read: Boolean(payload.read) }) } diff --git a/server/repositories/analytics-repository.js b/server/repositories/analytics-repository.js index 90384ff..6459ee1 100644 --- a/server/repositories/analytics-repository.js +++ b/server/repositories/analytics-repository.js @@ -75,6 +75,35 @@ const ensurePageDailyRow = async (sql, day, pageId) => { ` } +/** + * 일별 유입 통계 행을 보장한다. + * @param {import('postgres').Sql} sql - DB 클라이언트 + * @param {string} day - YYYY-MM-DD + * @param {{ sourceGroup: string, sourceName: string, deviceType: string, osName: string, keyword?: string }} traffic - 유입 분류 + * @returns {Promise} + */ +const ensureTrafficDailyRow = async (sql, day, traffic) => { + await sql` + INSERT INTO analytics_traffic_daily ( + day, + source_group, + source_name, + device_type, + os_name, + keyword + ) + VALUES ( + ${day}, + ${traffic.sourceGroup}, + ${traffic.sourceName}, + ${traffic.deviceType}, + ${traffic.osName}, + ${traffic.keyword || ''} + ) + ON CONFLICT (day, source_group, source_name, device_type, os_name, keyword) DO NOTHING + ` +} + /** * 사이트 방문자를 등록하고 신규 방문자면 true를 반환한다. * @param {import('postgres').Sql} sql - DB 클라이언트 @@ -131,9 +160,72 @@ const registerPageVisitor = async (sql, day, pageId, visitorHash) => { return Boolean(rows[0]) } +/** + * 유입 분류별 방문자를 등록하고 신규 방문자면 true를 반환한다. + * @param {import('postgres').Sql} sql - DB 클라이언트 + * @param {string} day - YYYY-MM-DD + * @param {string} visitorHash - 방문자 해시 + * @param {{ sourceGroup: string, sourceName: string, deviceType: string, osName: string, keyword?: string }} traffic - 유입 분류 + * @returns {Promise} 신규 방문자 여부 + */ +const registerTrafficVisitor = async (sql, day, visitorHash, traffic) => { + const rows = await sql` + INSERT INTO analytics_daily_visitors ( + day, + scope, + visitor_hash, + source_group, + source_name, + device_type, + os_name, + keyword + ) + VALUES ( + ${day}, + 'traffic', + ${visitorHash}, + ${traffic.sourceGroup}, + ${traffic.sourceName}, + ${traffic.deviceType}, + ${traffic.osName}, + ${traffic.keyword || ''} + ) + ON CONFLICT DO NOTHING + RETURNING id + ` + + return Boolean(rows[0]) +} + +/** + * 유입·디바이스 통계를 기록한다. + * @param {import('postgres').Sql} sql - DB 클라이언트 + * @param {string} day - YYYY-MM-DD + * @param {string} visitorHash - 방문자 해시 + * @param {{ sourceGroup: string, sourceName: string, deviceType: string, osName: string, keyword?: string }} traffic - 유입 분류 + * @returns {Promise} + */ +const recordAnalyticsTraffic = async (sql, day, visitorHash, traffic) => { + await ensureTrafficDailyRow(sql, day, traffic) + const isNewTrafficVisitor = await registerTrafficVisitor(sql, day, visitorHash, traffic) + + await sql` + UPDATE analytics_traffic_daily + SET + page_views = page_views + 1, + visitors = visitors + ${isNewTrafficVisitor ? 1 : 0} + WHERE day = ${day} + AND source_group = ${traffic.sourceGroup} + AND source_name = ${traffic.sourceName} + AND device_type = ${traffic.deviceType} + AND os_name = ${traffic.osName} + AND keyword = ${traffic.keyword || ''} + ` +} + /** * 페이지뷰·읽음 이벤트를 기록한다. - * @param {{ visitorHash: string, postId?: string | null, pageId?: string | null, recordSite?: boolean, recordView?: boolean, recordRead?: boolean }} input - 기록 입력 + * @param {{ visitorHash: string, postId?: string | null, pageId?: string | null, traffic?: { sourceGroup: string, sourceName: string, deviceType: string, osName: string, keyword?: string }, recordSite?: boolean, recordView?: boolean, recordRead?: boolean }} input - 기록 입력 * @returns {Promise<{ ok: true }>} */ export const recordAnalyticsPageview = async (input) => { @@ -150,6 +242,7 @@ export const recordAnalyticsPageview = async (input) => { const recordSite = input.recordSite !== false const recordView = Boolean(input.recordView) const recordRead = Boolean(input.recordRead) + const traffic = input.traffic || null if (recordSite) { await ensureSiteDailyRow(sql, day) @@ -163,6 +256,10 @@ export const recordAnalyticsPageview = async (input) => { visitors = visitors + ${isNewSiteVisitor ? 1 : 0} WHERE day = ${day} ` + + if (traffic) { + await recordAnalyticsTraffic(sql, day, visitorHash, traffic) + } } if (!postId) { @@ -755,6 +852,89 @@ export const getAnalyticsActiveSessions = async (options = {}) => { })) } +/** + * 관리자 유입·디바이스·키워드 통계를 조회한다. + * @param {{ days?: number }} [options] - 조회 옵션 + * @returns {Promise<{ sources: Array, devices: Array, keywords: Array, days: number }>} 유입 통계 + */ +export const getAnalyticsTrafficSummary = async (options = {}) => { + const sql = getPostgresClient() + const days = Math.min(Math.max(Number(options.days) || 30, 1), ANALYTICS_CHART_MAX_DAYS) + + if (!sql) { + return { + sources: [], + devices: [], + keywords: [], + days + } + } + + const today = getAnalyticsDayKey() + const rangeStartDay = getAnalyticsDayBefore(today, days - 1) + await purgeAnalyticsRetention(sql) + + const sourceRows = await sql` + SELECT + source_group, + source_name, + COALESCE(SUM(page_views), 0)::int AS page_views, + COALESCE(SUM(visitors), 0)::int AS visitors + FROM analytics_traffic_daily + WHERE day >= ${rangeStartDay}::date + GROUP BY source_group, source_name + ORDER BY page_views DESC, visitors DESC, source_name ASC + ` + + const deviceRows = await sql` + SELECT + device_type, + os_name, + COALESCE(SUM(page_views), 0)::int AS page_views, + COALESCE(SUM(visitors), 0)::int AS visitors + FROM analytics_traffic_daily + WHERE day >= ${rangeStartDay}::date + GROUP BY device_type, os_name + ORDER BY page_views DESC, visitors DESC, device_type ASC, os_name ASC + ` + + const keywordRows = await sql` + SELECT + keyword, + source_name, + COALESCE(SUM(page_views), 0)::int AS page_views, + COALESCE(SUM(visitors), 0)::int AS visitors + FROM analytics_traffic_daily + WHERE day >= ${rangeStartDay}::date + AND keyword <> '' + GROUP BY keyword, source_name + ORDER BY page_views DESC, visitors DESC, keyword ASC + LIMIT 12 + ` + + return { + sources: sourceRows.map((row) => ({ + sourceGroup: row.source_group, + sourceName: row.source_name, + pageViews: Number(row.page_views || 0), + visitors: Number(row.visitors || 0) + })), + devices: deviceRows.map((row) => ({ + deviceType: row.device_type, + osName: row.os_name, + pageViews: Number(row.page_views || 0), + visitors: Number(row.visitors || 0) + })), + keywords: keywordRows.map((row) => ({ + keyword: row.keyword, + sourceName: row.source_name, + pageViews: Number(row.page_views || 0), + visitors: Number(row.visitors || 0) + })), + days + } +} + /** * 인기 게시물 통계를 조회한다. * @param {{ days?: number, limit?: number }} [options] - 조회 옵션 @@ -771,6 +951,7 @@ export const getAnalyticsTopPosts = async (options = {}) => { const today = getAnalyticsDayKey() const rangeStartDay = getAnalyticsDayBefore(today, days - 1) + const monthlyStartDay = getAnalyticsDayBefore(today, 29) await purgeAnalyticsRetention(sql) const rows = await sql` @@ -778,7 +959,11 @@ export const getAnalyticsTopPosts = async (options = {}) => { posts.id, posts.title, posts.slug, + posts.created_at, COALESCE(SUM(post_analytics_daily.views), 0)::int AS views, + COALESCE(SUM(post_analytics_daily.views) FILTER ( + WHERE post_analytics_daily.day >= ${monthlyStartDay}::date + ), 0)::int AS monthly_views, COALESCE(SUM(post_analytics_daily.reads), 0)::int AS reads, COALESCE(SUM(post_analytics_daily.visitors), 0)::int AS visitors, COALESCE(SUM(post_analytics_daily.engaged_views), 0)::int AS engaged_views, @@ -789,7 +974,7 @@ export const getAnalyticsTopPosts = async (options = {}) => { FROM post_analytics_daily INNER JOIN posts ON posts.id = post_analytics_daily.post_id WHERE post_analytics_daily.day >= ${rangeStartDay}::date - GROUP BY posts.id, posts.title, posts.slug + GROUP BY posts.id, posts.title, posts.slug, posts.created_at ORDER BY views DESC, reads DESC, posts.published_at DESC NULLS LAST LIMIT ${limit} ` @@ -802,7 +987,9 @@ export const getAnalyticsTopPosts = async (options = {}) => { id: row.id, title: row.title, slug: row.slug, + createdAt: row.created_at ? new Date(row.created_at).toISOString() : null, views: Number(row.views || 0), + monthlyViews: Number(row.monthly_views || 0), reads: Number(row.reads || 0), visitors: Number(row.visitors || 0), avgEngagedSeconds: engagedViews > 0 diff --git a/server/routes/admin/api/analytics/traffic.get.js b/server/routes/admin/api/analytics/traffic.get.js new file mode 100644 index 0000000..3db4281 --- /dev/null +++ b/server/routes/admin/api/analytics/traffic.get.js @@ -0,0 +1,16 @@ +import { requireAdminSession } from '../../../../utils/admin-auth.js' +import { getAnalyticsTrafficSummary } from '../../../../repositories/analytics-repository.js' + +/** + * 관리자 유입 통계 API + * @param {import('h3').H3Event} event - 요청 이벤트 + * @returns {Promise} 유입 통계 + */ +export default defineEventHandler(async (event) => { + requireAdminSession(event) + + const query = getQuery(event) + const days = Number(query.days) || 30 + + return getAnalyticsTrafficSummary({ days }) +}) diff --git a/server/utils/analytics-pageview-input.js b/server/utils/analytics-pageview-input.js index 6285185..2f6aa6d 100644 --- a/server/utils/analytics-pageview-input.js +++ b/server/utils/analytics-pageview-input.js @@ -5,6 +5,10 @@ import { normalizePageSlugForAnalytics, normalizePostSlugForAnalytics } from '../../lib/analytics.js' +import { + classifyAnalyticsDevice, + classifyAnalyticsTrafficSource +} from '../../lib/analytics-traffic.js' import { getPageBySlug, getPostBySlug } from '../repositories/content-repository.js' import { createVisitorHashFromEvent, @@ -15,6 +19,8 @@ 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(''), + referrer: z.string().trim().max(1000).optional().default(''), + currentUrl: z.string().trim().max(1000).optional().default(''), read: z.boolean().optional().default(false) }) @@ -67,11 +73,19 @@ export const handleAnalyticsPageview = async (event) => { const visitorHash = createVisitorHashFromEvent(event) const isReadEvent = Boolean(body.read) + const traffic = { + ...classifyAnalyticsTrafficSource({ + referrer: body.referrer, + currentUrl: body.currentUrl + }), + ...classifyAnalyticsDevice(userAgent) + } await recordAnalyticsPageview({ visitorHash, postId, pageId, + traffic, recordSite: !isReadEvent, recordView: (Boolean(postId) || Boolean(pageId)) && !isReadEvent, recordRead: Boolean(postId) && isReadEvent