대시보드 AdSense 요약 추가

This commit is contained in:
2026-07-20 16:57:09 +09:00
parent 14c53826c6
commit e7c4f952a6
12 changed files with 525 additions and 6 deletions

View File

@@ -35,6 +35,7 @@ NUXT_PUBLIC_SITE_TITLE=sori.studio
# GOOGLE_ADSENSE_CLIENT_SECRET=
# GOOGLE_ADSENSE_REFRESH_TOKEN=
# GOOGLE_ADSENSE_ACCOUNT_NAME=accounts/pub-1234567890123456
# GOOGLE_ADSENSE_CURRENCY_CODE=KRW
# GOOGLE_ADSENSE_CREDENTIALS_PATH= ← 선택. 토큰 발급 스크립트에서 credentials.json을 직접 읽을 때만 사용.
# GOOGLE_ADSENSE_OAUTH_PORT=43082

View File

@@ -1,5 +1,9 @@
# 업데이트 요약
## v1.10.80
- 관리자 대시보드에 AdSense 예상 수익·페이지뷰·노출·클릭 요약 영역을 추가했다.
## v1.10.79
- AdSense 통계 연동 준비를 위해 refresh token 발급 스크립트와 환경 변수 안내를 추가했다.

View File

@@ -1,6 +1,6 @@
# 배포 가이드
> 로컬 기준 v1.10.79에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
> 로컬 기준 v1.10.80에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
## 빌드 유형
@@ -129,7 +129,7 @@ cp .env.example .env.production
# 운영 DB에 owner/admin이 없으면 /admin/login에서 ADMIN_EMAIL/ADMIN_PASSWORD로 최초 owner 계정이 생성됨
# MEMBER_SESSION_SECRET은 ADMIN_PASSWORD와 다른 긴 난수 문자열로 반드시 입력
# AdSense 통계를 대시보드에 연동하려면 GOOGLE_ADSENSE_CLIENT_ID, GOOGLE_ADSENSE_CLIENT_SECRET,
# GOOGLE_ADSENSE_REFRESH_TOKEN, GOOGLE_ADSENSE_ACCOUNT_NAME을 추가로 입력
# GOOGLE_ADSENSE_REFRESH_TOKEN, GOOGLE_ADSENSE_ACCOUNT_NAME, GOOGLE_ADSENSE_CURRENCY_CODE를 추가로 입력
# Docker 네트워크 대역이 NAS 기존 컨테이너와 겹치면 DOCKER_SUBNET을 다른 사설 대역으로 변경
# Docker 내부 앱에서 PostgreSQL에 접근할 때는 sori-studio-db:5432 사용
@@ -197,7 +197,7 @@ GOOGLE_ADSENSE_CREDENTIALS_PATH=/path/to/credentials.json npm run adsense:token
ENV_FILE=.env.production npm run adsense:token
```
스크립트가 표시하는 `http://127.0.0.1:43082/oauth2callback` 주소는 Google Cloud OAuth 클라이언트의 승인된 리다이렉트 URI에 등록되어 있어야 한다. 브라우저 승인 후 터미널에 출력되는 `GOOGLE_ADSENSE_REFRESH_TOKEN`과 `GOOGLE_ADSENSE_ACCOUNT_NAME`을 `.env.production`에 저장한다. 광고 코드의 `data-ad-client="ca-pub-..."` 값은 API 계정 이름으로 쓸 때 `ca-`를 빼고 `accounts/pub-...` 형식으로 적는다.
스크립트가 표시하는 `http://127.0.0.1:43082/oauth2callback` 주소는 Google Cloud OAuth 클라이언트의 승인된 리다이렉트 URI에 등록되어 있어야 한다. 브라우저 승인 후 터미널에 출력되는 `GOOGLE_ADSENSE_REFRESH_TOKEN`과 `GOOGLE_ADSENSE_ACCOUNT_NAME`을 `.env.production`에 저장한다. 광고 코드의 `data-ad-client="ca-pub-..."` 값은 API 계정 이름으로 쓸 때 `ca-`를 빼고 `accounts/pub-...` 형식으로 적는다. 대시보드 표시 통화는 필요하면 `GOOGLE_ADSENSE_CURRENCY_CODE=KRW`처럼 지정한다.
### Docker 네트워크 충돌 대응

View File

@@ -1,3 +1,9 @@
## 2026-07-20 v1.10.80 — AdSense 비밀값은 서버에서만 다룬다
관리자 대시보드에 AdSense 수익 통계를 표시하려면 Google OAuth client secret과 refresh token이 필요하다. 이 값은 브라우저에 노출되면 안 되므로 관리자 화면에서 직접 Google API를 호출하지 않고, 서버 전용 `/admin/api/adsense/summary`에서만 환경 변수를 읽어 access token을 발급한다.
AdSense API 실패가 대시보드 전체 장애로 보이면 운영자가 현재 접속자나 자체 통계까지 확인하지 못하므로, API 오류와 설정 누락은 AdSense 카드 내부 안내로 제한한다. 외부 보고서 조회는 오늘·어제·이번 달 세 구간만 요청해 관리자 첫 화면에서 필요한 기념성/운영성 숫자를 가볍게 확인하는 데 집중한다.
## 2026-07-20 v1.10.74 — 페이지 목록은 게시글 목록의 작업 화면 언어를 따른다
게시글과 페이지는 노출 방식은 다르지만 관리자 목록에서 운영자가 하는 일은 검색, 상태 확인, 통계 확인, 수정 진입으로 거의 같다. 따라서 페이지 목록도 게시글 목록과 같은 상단 검색·필터·정렬 구조와 추천/썸네일 열을 사용해 두 화면의 탐색 리듬을 맞춘다. 페이지 전용 서버 페이지네이션은 아직 두지 않고, 현재 API 응답 위에서 클라이언트 필터로 UI 일관성을 먼저 확보한다.

View File

@@ -352,6 +352,7 @@
| server/repositories/comment-repository.js | 댓글 조회/생성 저장소 |
| server/repositories/analytics-repository.js | 방문·게시물·페이지·유입 통계 집계·관리자 요약/날짜 상세 조회·방문자 해시 보관 정리 |
| server/utils/analytics-pageview-input.js | `POST /api/analytics/pageview` 검증·관리자 세션 제외·기록 |
| server/utils/adsense-report.js | AdSense OAuth access token 발급·보고서 조회·대시보드 요약 생성 |
| server/utils/zip-writer.js | 게시물 Export ZIP 생성 유틸리티 |
| server/utils/zip-reader.js | 게시물 Import ZIP 읽기 유틸리티 |
| server/api/analytics/pageview.post.js | 공개 통계 수집 API |
@@ -365,6 +366,7 @@
| server/routes/admin/api/analytics/realtime.get.js | 관리자 실시간 접속자 API |
| server/routes/admin/api/analytics/traffic.get.js | 관리자 유입원·디바이스·키워드 통계 API |
| server/routes/admin/api/analytics/range-detail.get.js | 관리자 통계 추이 날짜·구간 상세 API |
| server/routes/admin/api/adsense/summary.get.js | 관리자 대시보드 AdSense 예상 수익·페이지뷰·노출·클릭 요약 API |
| plugins/site-analytics.client.js | 공개 라우트 pageview·heartbeat·read 클라이언트 전송 |
| plugins/bfcache-page-restore.client.js | 뒤로가기 캐시(bfcache) 복원 시 라우터 경로와 실제 URL 어긋남을 감지해 새로고침(HTML 문서 모드 페이지 이동 후 뒤로가기 잔상 방지) |
| plugins/navigation-lock.client.js | 페이지 이동 중 `html.site-navigating` 클래스로 링크·버튼 클릭을 잠가 중복 클릭 차단(안전 타임아웃 포함) |

View File

@@ -167,6 +167,7 @@
- 각 값은 관리자만 입력하는 신뢰 콘텐츠를 전제로 하며, 애드센스에서 제공하는 반응형 또는 고정 크기 HTML 코드를 그대로 붙여 넣는다.
- 공개 화면은 `SiteAdSlot` 컴포넌트로 광고 슬롯을 렌더링한다. 값이 비어 있으면 DOM을 만들지 않고, 값이 있으면 클라이언트에서 HTML을 삽입한 뒤 내부 script를 실행 가능한 노드로 교체해 Nuxt 클라이언트 라우팅 후에도 광고 코드가 동작하게 한다.
- 공통 헤더·푸터 코드와 ads.txt는 기존 사이트 코드 카드에서 관리하고, 화면 위치가 필요한 광고 단위는 Ads 카드에서 관리한다.
- 관리자 대시보드의 AdSense 요약은 `/admin/api/adsense/summary`에서 조회한다. 서버는 `GOOGLE_ADSENSE_CLIENT_ID`, `GOOGLE_ADSENSE_CLIENT_SECRET`, `GOOGLE_ADSENSE_REFRESH_TOKEN`, `GOOGLE_ADSENSE_ACCOUNT_NAME`을 사용해 Google access token을 발급하고 AdSense Management API `reports:generate`로 오늘·어제·이번 달 `ESTIMATED_EARNINGS`, `PAGE_VIEWS`, `IMPRESSIONS`, `CLICKS`, `PAGE_VIEWS_RPM`을 조회한다. `GOOGLE_ADSENSE_CURRENCY_CODE`가 있으면 보고서 통화 코드로 전달한다. 설정 누락 또는 API 오류 시 대시보드 전체를 실패시키지 않고 AdSense 카드에 안내 문구만 표시한다.
### 공개 인증 화면(초기)

View File

@@ -1,5 +1,12 @@
# 업데이트 이력
## v1.10.80
- 관리자 대시보드 상단 요약 아래에 AdSense 요약 카드 추가.
- `/admin/api/adsense/summary` API 추가.
- AdSense refresh token 기반 access token 발급과 오늘·어제·이번 달 보고서 조회 유틸 추가.
- AdSense API 통화 코드 환경 변수 예시 추가.
## v1.10.79
- AdSense Management API refresh token 발급용 `npm run adsense:token` 스크립트 추가.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.10.79",
"version": "1.10.80",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.10.79",
"version": "1.10.80",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.10.79",
"version": "1.10.80",
"private": true,
"type": "module",
"imports": {

View File

@@ -62,6 +62,33 @@ const { data: realtime, refresh: refreshRealtime } = await useFetch('/admin/api/
})
})
const createEmptyAdsensePeriod = (label) => ({
label,
estimatedEarnings: 0,
pageViews: 0,
impressions: 0,
clicks: 0,
pageViewsRpm: 0
})
const {
data: adsenseSummary,
pending: isAdsenseLoading,
refresh: refreshAdsenseSummary
} = await useFetch('/admin/api/adsense/summary', {
server: false,
default: () => ({
configured: false,
ok: false,
message: '',
currencyCode: 'KRW',
generatedAt: '',
today: createEmptyAdsensePeriod('오늘'),
yesterday: createEmptyAdsensePeriod('어제'),
month: createEmptyAdsensePeriod('이번 달')
})
})
const todayKey = getAnalyticsDayKey()
const publishedCount = computed(() => posts.value.filter((post) => post.status === 'published').length)
const draftCount = computed(() => posts.value.filter((post) => post.status === 'draft').length)
@@ -152,6 +179,79 @@ const chartPagination = computed(() => {
return paginateAnalyticsTrendRows(trendRows.value, selectedAnalyticsPeriod.value, analyticsChartPageOffset.value)
})
const adsenseCards = computed(() => [
adsenseSummary.value.today || createEmptyAdsensePeriod('오늘'),
adsenseSummary.value.yesterday || createEmptyAdsensePeriod('어제'),
adsenseSummary.value.month || createEmptyAdsensePeriod('이번 달')
])
const adsenseGeneratedLabel = computed(() => {
const value = adsenseSummary.value?.generatedAt
if (!value) {
return ''
}
return formatCompactDateTime(value)
})
/**
* AdSense 통화 코드를 표시 가능한 값으로 보정한다.
* @returns {string} 통화 코드
*/
const getAdsenseCurrencyCode = () => {
const currencyCode = adsenseSummary.value?.currencyCode || 'KRW'
try {
new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: currencyCode
}).format(0)
return currencyCode
} catch {
return 'KRW'
}
}
/**
* 숫자를 천 단위 구분 문자열로 변환한다.
* @param {number} value - 숫자 값
* @returns {string} 표시 문자열
*/
const formatNumber = (value) => {
return new Intl.NumberFormat('ko-KR').format(Number(value || 0))
}
/**
* AdSense 금액을 통화 문자열로 변환한다.
* @param {number} value - 금액
* @returns {string} 표시 문자열
*/
const formatAdsenseCurrency = (value) => {
const currencyCode = getAdsenseCurrencyCode()
return new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: currencyCode,
maximumFractionDigits: currencyCode === 'KRW' ? 0 : 2
}).format(Number(value || 0))
}
/**
* AdSense RPM을 표시 문자열로 변환한다.
* @param {number} value - RPM
* @returns {string} 표시 문자열
*/
const formatAdsenseRpm = (value) => {
const currencyCode = getAdsenseCurrencyCode()
return new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: currencyCode,
maximumFractionDigits: currencyCode === 'KRW' ? 0 : 2
}).format(Number(value || 0))
}
/**
* 통계 차트 이전 기간으로 이동한다.
* @returns {void}
@@ -447,6 +547,29 @@ const formatCompactDate = (value) => {
return `${year}.${month}.${day}`
}
/**
* 통계 갱신 시각을 짧은 문자열로 변환한다.
* @param {string | null | undefined} value - ISO 날짜
* @returns {string} 표시 날짜와 시간
*/
const formatCompactDateTime = (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')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}.${month}.${day} ${hours}:${minutes}`
}
/**
* 스크롤 도달 지표를 짧은 문자열로 반환한다.
* @param {Object} row - 통계 행
@@ -562,6 +685,64 @@ watch(trendRows, () => {
</p>
</section>
<section class="admin-dashboard__adsense border border-line bg-white p-4">
<div class="admin-dashboard__adsense-header flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="admin-dashboard__section-title text-sm font-semibold text-ink">
AdSense
</h2>
<p class="admin-dashboard__section-description mt-1 text-xs text-muted">
예상 수익·페이지뷰·클릭 기준 요약
<span v-if="adsenseGeneratedLabel">· {{ adsenseGeneratedLabel }} 갱신</span>
</p>
</div>
<button
type="button"
class="admin-dashboard__adsense-refresh border border-line bg-white px-3 py-1 text-xs font-medium text-muted transition hover:bg-paper hover:text-ink disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isAdsenseLoading"
@click="refreshAdsenseSummary"
>
{{ isAdsenseLoading ? '갱신 중' : '새로고침' }}
</button>
</div>
<p
v-if="!adsenseSummary.configured"
class="admin-dashboard__adsense-message mt-3 border border-dashed border-line bg-paper px-3 py-2 text-xs text-muted"
>
AdSense 환경 변수를 설정하면 이곳에 수익 요약이 표시됩니다.
</p>
<p
v-else-if="!adsenseSummary.ok"
class="admin-dashboard__adsense-message mt-3 border border-red-100 bg-red-50 px-3 py-2 text-xs text-red-700"
>
{{ adsenseSummary.message || 'AdSense 통계를 불러오지 못했습니다.' }}
</p>
<div class="admin-dashboard__adsense-grid mt-3 grid gap-3 lg:grid-cols-3">
<article
v-for="card in adsenseCards"
:key="card.label"
class="admin-dashboard__adsense-card border border-line bg-paper p-3"
>
<div class="flex items-start justify-between gap-3">
<p class="text-xs font-semibold uppercase text-muted">
{{ card.label }}
</p>
<p class="text-[11px] text-muted">
RPM {{ formatAdsenseRpm(card.pageViewsRpm) }}
</p>
</div>
<strong class="mt-2 block text-xl text-ink">
{{ formatAdsenseCurrency(card.estimatedEarnings) }}
</strong>
<p class="mt-2 text-xs text-muted">
페이지뷰 {{ formatNumber(card.pageViews) }} · 노출 {{ formatNumber(card.impressions) }} · 클릭 {{ formatNumber(card.clicks) }}
</p>
</article>
</div>
</section>
<section class="admin-dashboard__charts border border-line bg-white p-4">
<div class="admin-dashboard__charts-header flex flex-wrap items-center justify-between gap-3">
<div>

View File

@@ -0,0 +1,13 @@
import { requireAdminSession } from '../../../../utils/admin-auth.js'
import { getAdsenseDashboardSummary } from '../../../../utils/adsense-report.js'
/**
* 관리자 대시보드 AdSense 요약 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Object>} AdSense 요약
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
return getAdsenseDashboardSummary()
})

View File

@@ -0,0 +1,304 @@
const ADSENSE_TOKEN_URL = 'https://oauth2.googleapis.com/token'
const ADSENSE_API_BASE_URL = 'https://adsense.googleapis.com/v2'
const ADSENSE_REPORT_METRICS = [
'ESTIMATED_EARNINGS',
'PAGE_VIEWS',
'IMPRESSIONS',
'CLICKS',
'PAGE_VIEWS_RPM'
]
let cachedAccessToken = ''
let cachedAccessTokenExpiresAt = 0
/**
* AdSense API 연동 설정을 반환한다.
* @returns {{ clientId: string, clientSecret: string, refreshToken: string, accountName: string, currencyCode: string }} AdSense 설정
*/
const getAdsenseConfig = () => ({
clientId: process.env.GOOGLE_ADSENSE_CLIENT_ID || '',
clientSecret: process.env.GOOGLE_ADSENSE_CLIENT_SECRET || '',
refreshToken: process.env.GOOGLE_ADSENSE_REFRESH_TOKEN || '',
accountName: process.env.GOOGLE_ADSENSE_ACCOUNT_NAME || '',
currencyCode: process.env.GOOGLE_ADSENSE_CURRENCY_CODE || ''
})
/**
* AdSense API 호출에 필요한 설정이 모두 있는지 확인한다.
* @param {ReturnType<typeof getAdsenseConfig>} config - AdSense 설정
* @returns {boolean} 설정 완료 여부
*/
const isAdsenseConfigured = (config) => {
return Boolean(config.clientId && config.clientSecret && config.refreshToken && config.accountName)
}
/**
* Date 객체를 Google API Date 필드로 변환한다.
* @param {Date} date - 날짜
* @returns {{ year: string, month: string, day: string }} Google API Date 필드
*/
const toGoogleDateFields = (date) => ({
year: String(date.getFullYear()),
month: String(date.getMonth() + 1),
day: String(date.getDate())
})
/**
* 로컬 시간 기준 날짜 시작점을 만든다.
* @param {Date} date - 기준 날짜
* @returns {Date} 날짜 시작점
*/
const startOfLocalDay = (date) => new Date(date.getFullYear(), date.getMonth(), date.getDate())
/**
* 날짜에 일 단위를 더한다.
* @param {Date} date - 기준 날짜
* @param {number} days - 더할 일수
* @returns {Date} 계산된 날짜
*/
const addDays = (date, days) => {
const nextDate = new Date(date)
nextDate.setDate(nextDate.getDate() + days)
return nextDate
}
/**
* 이번 달 시작 날짜를 만든다.
* @param {Date} date - 기준 날짜
* @returns {Date} 이번 달 시작 날짜
*/
const startOfLocalMonth = (date) => new Date(date.getFullYear(), date.getMonth(), 1)
/**
* 빈 AdSense 지표를 만든다.
* @param {string} label - 기간 라벨
* @returns {Object} 빈 지표
*/
const createEmptyAdsenseMetric = (label) => ({
label,
estimatedEarnings: 0,
pageViews: 0,
impressions: 0,
clicks: 0,
pageViewsRpm: 0
})
/**
* 숫자 셀 값을 안전하게 변환한다.
* @param {unknown} value - 원본 값
* @returns {number} 숫자 값
*/
const toMetricNumber = (value) => {
const number = Number(value || 0)
return Number.isFinite(number) ? number : 0
}
/**
* Google OAuth refresh token으로 access token을 발급한다.
* @param {ReturnType<typeof getAdsenseConfig>} config - AdSense 설정
* @returns {Promise<string>} access token
*/
const getAdsenseAccessToken = async (config) => {
const now = Date.now()
if (cachedAccessToken && cachedAccessTokenExpiresAt > now + 60000) {
return cachedAccessToken
}
const response = await fetch(ADSENSE_TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
client_id: config.clientId,
client_secret: config.clientSecret,
refresh_token: config.refreshToken,
grant_type: 'refresh_token'
})
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(data.error_description || data.error || 'AdSense access token 발급 실패')
}
cachedAccessToken = data.access_token || ''
cachedAccessTokenExpiresAt = now + (Number(data.expires_in || 0) * 1000)
if (!cachedAccessToken) {
throw new Error('AdSense access token 응답이 비어 있습니다.')
}
return cachedAccessToken
}
/**
* AdSense 보고서 URL을 만든다.
* @param {Object} options - 보고서 옵션
* @param {string} options.accountName - AdSense 계정 이름
* @param {Date} options.startDate - 시작일
* @param {Date} options.endDate - 종료일
* @param {string} options.currencyCode - 통화 코드
* @returns {URL} 보고서 URL
*/
const createAdsenseReportUrl = ({ accountName, startDate, endDate, currencyCode }) => {
const url = new URL(`${ADSENSE_API_BASE_URL}/${accountName}/reports:generate`)
const start = toGoogleDateFields(startDate)
const end = toGoogleDateFields(endDate)
for (const metric of ADSENSE_REPORT_METRICS) {
url.searchParams.append('metrics', metric)
}
url.searchParams.set('startDate.year', start.year)
url.searchParams.set('startDate.month', start.month)
url.searchParams.set('startDate.day', start.day)
url.searchParams.set('endDate.year', end.year)
url.searchParams.set('endDate.month', end.month)
url.searchParams.set('endDate.day', end.day)
url.searchParams.set('languageCode', 'ko-KR')
if (currencyCode) {
url.searchParams.set('currencyCode', currencyCode)
}
return url
}
/**
* AdSense 단일 기간 보고서를 조회한다.
* @param {Object} options - 조회 옵션
* @param {string} options.label - 기간 라벨
* @param {string} options.accessToken - access token
* @param {ReturnType<typeof getAdsenseConfig>} options.config - AdSense 설정
* @param {Date} options.startDate - 시작일
* @param {Date} options.endDate - 종료일
* @returns {Promise<Object>} 기간 지표
*/
const fetchAdsensePeriodReport = async ({ label, accessToken, config, startDate, endDate }) => {
const url = createAdsenseReportUrl({
accountName: config.accountName,
startDate,
endDate,
currencyCode: config.currencyCode
})
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`
}
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(data.error?.message || `AdSense 보고서 조회 실패: ${response.status}`)
}
const row = Array.isArray(data.rows) ? data.rows[0] : null
const cells = row && Array.isArray(row.cells) ? row.cells : []
const headers = Array.isArray(data.headers) ? data.headers : []
const metric = createEmptyAdsenseMetric(label)
headers.forEach((header, index) => {
const value = toMetricNumber(cells[index]?.value)
if (header.name === 'ESTIMATED_EARNINGS') {
metric.estimatedEarnings = value
return
}
if (header.name === 'PAGE_VIEWS') {
metric.pageViews = value
return
}
if (header.name === 'IMPRESSIONS') {
metric.impressions = value
return
}
if (header.name === 'CLICKS') {
metric.clicks = value
return
}
if (header.name === 'PAGE_VIEWS_RPM') {
metric.pageViewsRpm = value
}
})
return metric
}
/**
* 관리자 대시보드용 AdSense 요약을 조회한다.
* @returns {Promise<Object>} AdSense 요약
*/
export const getAdsenseDashboardSummary = async () => {
const config = getAdsenseConfig()
if (!isAdsenseConfigured(config)) {
return {
configured: false,
ok: false,
message: 'AdSense API 환경 변수가 아직 설정되지 않았습니다.',
currencyCode: config.currencyCode || 'KRW',
generatedAt: new Date().toISOString(),
today: createEmptyAdsenseMetric('오늘'),
yesterday: createEmptyAdsenseMetric('어제'),
month: createEmptyAdsenseMetric('이번 달')
}
}
try {
const today = startOfLocalDay(new Date())
const yesterday = addDays(today, -1)
const monthStart = startOfLocalMonth(today)
const accessToken = await getAdsenseAccessToken(config)
const [todayMetric, yesterdayMetric, monthMetric] = await Promise.all([
fetchAdsensePeriodReport({
label: '오늘',
accessToken,
config,
startDate: today,
endDate: today
}),
fetchAdsensePeriodReport({
label: '어제',
accessToken,
config,
startDate: yesterday,
endDate: yesterday
}),
fetchAdsensePeriodReport({
label: '이번 달',
accessToken,
config,
startDate: monthStart,
endDate: today
})
])
return {
configured: true,
ok: true,
message: '',
currencyCode: config.currencyCode || 'KRW',
generatedAt: new Date().toISOString(),
today: todayMetric,
yesterday: yesterdayMetric,
month: monthMetric
}
} catch (error) {
return {
configured: true,
ok: false,
message: error instanceof Error ? error.message : 'AdSense 통계를 불러오지 못했습니다.',
currencyCode: config.currencyCode || 'KRW',
generatedAt: new Date().toISOString(),
today: createEmptyAdsenseMetric('오늘'),
yesterday: createEmptyAdsenseMetric('어제'),
month: createEmptyAdsenseMetric('이번 달')
}
}
}