Files
sori.studio/server/utils/adsense-report.js

386 lines
12 KiB
JavaScript

const ADSENSE_TOKEN_URL = 'https://oauth2.googleapis.com/token'
const ADSENSE_API_BASE_URL = 'https://adsense.googleapis.com/v2'
const ADSENSE_PAYMENT_UNPAID_SUFFIX = '/payments/unpaid'
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} 이번 달 시작 날짜
*/
/**
* 빈 AdSense 지표를 만든다.
* @param {string} label - 기간 라벨
* @param {string} key - 기간 키
* @returns {Object} 빈 지표
*/
const createEmptyAdsenseMetric = (label, key = '') => ({
key,
label,
estimatedEarnings: 0,
pageViews: 0,
impressions: 0,
clicks: 0,
pageViewsRpm: 0,
currencyCode: ''
})
/**
* 빈 AdSense 지급 요약을 만든다.
* @returns {{ balanceAmount: string, lastPaymentAmount: string, lastPaymentDate: string }} 지급 요약
*/
const createEmptyAdsensePaymentSummary = () => ({
balanceAmount: '',
lastPaymentAmount: '',
lastPaymentDate: ''
})
/**
* 숫자 셀 값을 안전하게 변환한다.
* @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.dateRange] - AdSense 상대 기간
* @param {string} options.currencyCode - 통화 코드
* @returns {URL} 보고서 URL
*/
const createAdsenseReportUrl = ({ accountName, startDate, endDate, dateRange, currencyCode }) => {
const url = new URL(`${ADSENSE_API_BASE_URL}/${accountName}/reports:generate`)
for (const metric of ADSENSE_REPORT_METRICS) {
url.searchParams.append('metrics', metric)
}
if (dateRange) {
url.searchParams.set('dateRange', dateRange)
} else if (startDate && endDate) {
const start = toGoogleDateFields(startDate)
const end = toGoogleDateFields(endDate)
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.key - 기간 키
* @param {string} options.accessToken - access token
* @param {ReturnType<typeof getAdsenseConfig>} options.config - AdSense 설정
* @param {Date} [options.startDate] - 시작일
* @param {Date} [options.endDate] - 종료일
* @param {string} [options.dateRange] - AdSense 상대 기간
* @returns {Promise<Object>} 기간 지표
*/
const fetchAdsensePeriodReport = async ({ label, key, accessToken, config, startDate, endDate, dateRange }) => {
const url = createAdsenseReportUrl({
accountName: config.accountName,
startDate,
endDate,
dateRange,
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 = data.totals || (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, key)
headers.forEach((header, index) => {
const value = toMetricNumber(cells[index]?.value)
if (header.name === 'ESTIMATED_EARNINGS') {
metric.estimatedEarnings = value
metric.currencyCode = header.currencyCode || metric.currencyCode
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 지급 요약을 조회한다.
* @param {Object} options - 조회 옵션
* @param {string} options.accessToken - access token
* @param {ReturnType<typeof getAdsenseConfig>} options.config - AdSense 설정
* @returns {Promise<ReturnType<typeof createEmptyAdsensePaymentSummary>>} 지급 요약
*/
const fetchAdsensePaymentSummary = async ({ accessToken, config }) => {
const response = await fetch(`${ADSENSE_API_BASE_URL}/${config.accountName}/payments`, {
headers: {
Authorization: `Bearer ${accessToken}`
}
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(data.error?.message || `AdSense 지급 정보 조회 실패: ${response.status}`)
}
const payments = Array.isArray(data.payments) ? data.payments : []
const unpaid = payments.find((payment) => String(payment.name || '').endsWith(ADSENSE_PAYMENT_UNPAID_SUFFIX))
const paid = payments.find((payment) => !String(payment.name || '').includes('/youtube-') && payment.date)
return {
balanceAmount: unpaid?.amount || '',
lastPaymentAmount: paid?.amount || '',
lastPaymentDate: paid?.date
? [paid.date.year, String(paid.date.month).padStart(2, '0'), String(paid.date.day).padStart(2, '0')].join('.')
: ''
}
}
/**
* AdSense 지표 목록에서 표시 통화 코드를 고른다.
* @param {Object[]} periods - 기간 지표 목록
* @param {ReturnType<typeof getAdsenseConfig>} config - AdSense 설정
* @returns {string} 통화 코드
*/
const resolveAdsenseCurrencyCode = (periods, config) => {
return periods.find((period) => period.currencyCode)?.currencyCode || config.currencyCode || 'USD'
}
/**
* 관리자 대시보드용 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 || 'USD',
generatedAt: new Date().toISOString(),
periods: [],
balance: createEmptyAdsensePaymentSummary()
}
}
try {
const today = startOfLocalDay(new Date())
const recent28Start = addDays(today, -27)
const accessToken = await getAdsenseAccessToken(config)
const [todayMetric, yesterdayMetric, last7DaysMetric, monthMetric, recent28DaysMetric, balance] = await Promise.all([
fetchAdsensePeriodReport({
label: '오늘 현재까지',
key: 'today',
accessToken,
config,
dateRange: 'TODAY'
}),
fetchAdsensePeriodReport({
label: '어제',
key: 'yesterday',
accessToken,
config,
dateRange: 'YESTERDAY'
}),
fetchAdsensePeriodReport({
label: '지난 7일',
key: 'last7Days',
accessToken,
config,
dateRange: 'LAST_7_DAYS'
}),
fetchAdsensePeriodReport({
label: '이번 달',
key: 'month',
accessToken,
config,
dateRange: 'MONTH_TO_DATE'
}),
fetchAdsensePeriodReport({
label: '최근 28일',
key: 'recent28Days',
accessToken,
config,
startDate: recent28Start,
endDate: today
}),
fetchAdsensePaymentSummary({
accessToken,
config
})
])
const periods = [todayMetric, yesterdayMetric, last7DaysMetric, monthMetric, recent28DaysMetric]
return {
configured: true,
ok: true,
message: '',
currencyCode: resolveAdsenseCurrencyCode(periods, config),
generatedAt: new Date().toISOString(),
periods,
balance
}
} catch (error) {
return {
configured: true,
ok: false,
message: error instanceof Error ? error.message : 'AdSense 통계를 불러오지 못했습니다.',
currencyCode: config.currencyCode || 'USD',
generatedAt: new Date().toISOString(),
periods: [],
balance: createEmptyAdsensePaymentSummary()
}
}
}