From 14c53826c64a88684179a8794588d46ebd6c341c Mon Sep 17 00:00:00 2001 From: zenn Date: Mon, 20 Jul 2026 16:29:39 +0900 Subject: [PATCH] =?UTF-8?q?AdSense=20=ED=86=A0=ED=81=B0=20=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 8 + docs/changelog.md | 4 + docs/deploy.md | 18 +- docs/map.md | 1 + docs/update.md | 5 + package-lock.json | 4 +- package.json | 3 +- scripts/generate-adsense-token.js | 284 ++++++++++++++++++++++++++++++ 8 files changed, 323 insertions(+), 4 deletions(-) create mode 100644 scripts/generate-adsense-token.js diff --git a/.env.example b/.env.example index 1a18975..49998bd 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,14 @@ AVATAR_WEBP_QUALITY=82 NUXT_PUBLIC_SITE_URL=https://sori.studio NUXT_PUBLIC_SITE_TITLE=sori.studio +# Google AdSense Management API (optional — 관리자 대시보드 수익 통계) +# GOOGLE_ADSENSE_CLIENT_ID= +# GOOGLE_ADSENSE_CLIENT_SECRET= +# GOOGLE_ADSENSE_REFRESH_TOKEN= +# GOOGLE_ADSENSE_ACCOUNT_NAME=accounts/pub-1234567890123456 +# GOOGLE_ADSENSE_CREDENTIALS_PATH= ← 선택. 토큰 발급 스크립트에서 credentials.json을 직접 읽을 때만 사용. +# GOOGLE_ADSENSE_OAUTH_PORT=43082 + # Transactional email (Resend, optional — 회원가입 OTP·비밀번호 찾기) # RESEND_API_KEY= # RESEND_FROM_EMAIL=noreply@yourdomain.com diff --git a/docs/changelog.md b/docs/changelog.md index adb59df..94fbf30 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,9 @@ # 업데이트 요약 +## v1.10.79 + +- AdSense 통계 연동 준비를 위해 refresh token 발급 스크립트와 환경 변수 안내를 추가했다. + ## v1.10.78 - 대시보드 디바이스 통계를 방문자·조회·비율이 한눈에 보이도록 정리했다. diff --git a/docs/deploy.md b/docs/deploy.md index 84bc21f..f17f58a 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -1,6 +1,6 @@ # 배포 가이드 -> 로컬 기준 v1.10.78에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다. +> 로컬 기준 v1.10.79에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다. ## 빌드 유형 @@ -128,6 +128,8 @@ cp .env.example .env.production # .env.production 파일에 운영 DB 연결 정보, 운영 전용 랜덤 비밀번호, APP_PORT=43080, DB_PORT=43081 입력 # 운영 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을 추가로 입력 # Docker 네트워크 대역이 NAS 기존 컨테이너와 겹치면 DOCKER_SUBNET을 다른 사설 대역으로 변경 # Docker 내부 앱에서 PostgreSQL에 접근할 때는 sori-studio-db:5432 사용 @@ -183,6 +185,20 @@ docker compose --env-file .env.production up -d 코드 변경 없이 `.env.production`만 수정했다면 `--build` 없이 `up -d`만으로 충분하다. +### AdSense refresh token 발급 + +AdSense Management API를 관리자 대시보드에서 조회하려면 OAuth 클라이언트 ID/시크릿 외에 1회 발급한 refresh token이 필요하다. + +```bash +# credentials.json 경로를 직접 지정해서 실행 +GOOGLE_ADSENSE_CREDENTIALS_PATH=/path/to/credentials.json npm run adsense:token + +# 또는 env 파일에 GOOGLE_ADSENSE_CLIENT_ID / GOOGLE_ADSENSE_CLIENT_SECRET을 넣고 실행 +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-...` 형식으로 적는다. + ### Docker 네트워크 충돌 대응 NAS에 Docker 컨테이너가 많이 실행 중이면 `could not find an available, non-overlapping IPv4 address pool` 오류가 날 수 있다. 이 프로젝트는 기본 `DOCKER_SUBNET=10.250.50.0/24`를 사용한다. 해당 대역도 NAS 내부망 또는 다른 Docker 네트워크와 겹치면 `.env.production`에서 예를 들어 아래처럼 바꾼 뒤 다시 실행한다. diff --git a/docs/map.md b/docs/map.md index e68b85f..1e97f48 100644 --- a/docs/map.md +++ b/docs/map.md @@ -73,6 +73,7 @@ | scripts/backfill-post-thumbnails-production.sh | npm 없는 NAS 호스트에서 앱 컨테이너 내부 Node로 카드 썸네일 백필 실행 | | scripts/backfill-post-body-thumbnails-production.sh | npm 없는 NAS 호스트에서 앱 컨테이너 내부 Node로 본문 표시용 WebP 백필 실행 | | scripts/backfill-page-cover-thumbnails-production.sh | npm 없는 NAS 호스트에서 앱 컨테이너 내부 Node로 Pages 커버 썸네일 백필 실행 | +| scripts/generate-adsense-token.js | AdSense Management API refresh token 발급과 접근 가능한 계정 이름 확인 | ## DB 마이그레이션 diff --git a/docs/update.md b/docs/update.md index 3f78e28..d244df1 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 이력 +## v1.10.79 + +- AdSense Management API refresh token 발급용 `npm run adsense:token` 스크립트 추가. +- AdSense 관리자 대시보드 연동에 필요한 환경 변수 예시와 배포 안내 추가. + ## v1.10.78 - 관리자 대시보드 디바이스 통계에서 모바일·PC 집계 행을 `방문자 / 조회 / 비율` 한 줄 요약으로 정리. diff --git a/package-lock.json b/package-lock.json index 158455a..c5b25db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.10.78", + "version": "1.10.79", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.10.78", + "version": "1.10.79", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 58d7b71..f9f4ab8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.10.78", + "version": "1.10.79", "private": true, "type": "module", "imports": { @@ -25,6 +25,7 @@ "images:backfill-post-thumbnails": "node scripts/backfill-post-thumbnails.js", "images:backfill-post-body-thumbnails": "node scripts/backfill-post-body-thumbnails.js", "images:backfill-page-cover-thumbnails": "node scripts/backfill-page-cover-thumbnails.js", + "adsense:token": "node scripts/generate-adsense-token.js", "navigation:refresh-recommended-favicons": "node scripts/refresh-recommended-favicons.js", "postinstall": "nuxt prepare" }, diff --git a/scripts/generate-adsense-token.js b/scripts/generate-adsense-token.js new file mode 100644 index 0000000..f8e74e9 --- /dev/null +++ b/scripts/generate-adsense-token.js @@ -0,0 +1,284 @@ +import { existsSync, readFileSync } from 'node:fs' +import { createServer } from 'node:http' +import { randomUUID } from 'node:crypto' +import { URL, URLSearchParams } from 'node:url' + +const DEFAULT_SCOPE = 'https://www.googleapis.com/auth/adsense.readonly' +const DEFAULT_REDIRECT_PORT = 43082 +const OAUTH_AUTHORIZE_URL = 'https://accounts.google.com/o/oauth2/v2/auth' +const OAUTH_TOKEN_URL = 'https://oauth2.googleapis.com/token' +const ADSENSE_ACCOUNTS_URL = 'https://adsense.googleapis.com/v2/accounts' + +/** + * env 파일의 단순 KEY=VALUE 값을 현재 프로세스 환경 변수에 반영한다. + * @param {string} filePath - env 파일 경로 + * @returns {void} + */ +const loadEnvFile = (filePath) => { + if (!filePath || !existsSync(filePath)) { + return + } + + const lines = readFileSync(filePath, 'utf8').split(/\r?\n/) + + for (const line of lines) { + const trimmedLine = line.trim() + + if (!trimmedLine || trimmedLine.startsWith('#')) { + continue + } + + const separatorIndex = trimmedLine.indexOf('=') + + if (separatorIndex < 1) { + continue + } + + const key = trimmedLine.slice(0, separatorIndex).trim() + let value = trimmedLine.slice(separatorIndex + 1).trim() + + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1) + } + + if (!process.env[key]) { + process.env[key] = value + } + } +} + +/** + * credentials.json에서 OAuth 클라이언트 정보를 읽는다. + * @param {string} filePath - credentials.json 파일 경로 + * @returns {{ clientId: string, clientSecret: string }} OAuth 클라이언트 정보 + */ +const readCredentialsFile = (filePath) => { + const credentials = JSON.parse(readFileSync(filePath, 'utf8')) + const client = credentials.web || credentials.installed || credentials + + return { + clientId: client.client_id || '', + clientSecret: client.client_secret || '' + } +} + +/** + * 환경 변수와 credentials 파일에서 OAuth 클라이언트 정보를 가져온다. + * @returns {{ clientId: string, clientSecret: string }} OAuth 클라이언트 정보 + */ +const resolveOAuthClient = () => { + const credentialsPath = process.env.GOOGLE_ADSENSE_CREDENTIALS_PATH || '' + + if (credentialsPath) { + return readCredentialsFile(credentialsPath) + } + + return { + clientId: process.env.GOOGLE_ADSENSE_CLIENT_ID || '', + clientSecret: process.env.GOOGLE_ADSENSE_CLIENT_SECRET || '' + } +} + +/** + * 한 번만 쓰는 로컬 OAuth 콜백 서버를 열고 인증 코드를 기다린다. + * @param {number} port - 로컬 콜백 포트 + * @param {string} state - CSRF 방지용 state 값 + * @returns {Promise<{ code: string, redirectUri: string }>} 인증 코드와 리다이렉트 URI + */ +const waitForAuthorizationCode = (port, state) => { + const redirectUri = `http://127.0.0.1:${port}/oauth2callback` + + return new Promise((resolve, reject) => { + const server = createServer((request, response) => { + const requestUrl = new URL(request.url || '/', redirectUri) + + if (requestUrl.pathname !== '/oauth2callback') { + response.writeHead(404) + response.end('Not found') + return + } + + const receivedState = requestUrl.searchParams.get('state') || '' + const code = requestUrl.searchParams.get('code') || '' + const error = requestUrl.searchParams.get('error') || '' + + if (error) { + response.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' }) + response.end(`AdSense OAuth 승인 실패: ${error}`) + server.close() + reject(new Error(`AdSense OAuth 승인 실패: ${error}`)) + return + } + + if (!code || receivedState !== state) { + response.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' }) + response.end('AdSense OAuth 승인 응답이 올바르지 않습니다.') + server.close() + reject(new Error('AdSense OAuth 승인 응답이 올바르지 않습니다.')) + return + } + + response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) + response.end('

AdSense OAuth 승인이 완료되었습니다. 터미널로 돌아가세요.

') + server.close() + resolve({ code, redirectUri }) + }) + + server.once('error', reject) + server.listen(port, '127.0.0.1') + }) +} + +/** + * 인증 코드를 access token과 refresh token으로 교환한다. + * @param {Object} options - 토큰 교환 옵션 + * @param {string} options.clientId - OAuth 클라이언트 ID + * @param {string} options.clientSecret - OAuth 클라이언트 시크릿 + * @param {string} options.code - 승인 코드 + * @param {string} options.redirectUri - 리다이렉트 URI + * @returns {Promise<{ access_token?: string, refresh_token?: string }>} OAuth 토큰 응답 + */ +const exchangeAuthorizationCode = async ({ clientId, clientSecret, code, redirectUri }) => { + const response = await fetch(OAUTH_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + code, + grant_type: 'authorization_code', + redirect_uri: redirectUri + }) + }) + + const data = await response.json().catch(() => ({})) + + if (!response.ok) { + throw new Error(`토큰 교환 실패: ${data.error_description || data.error || response.status}`) + } + + return data +} + +/** + * access token으로 접근 가능한 AdSense 계정 목록을 조회한다. + * @param {string} accessToken - OAuth access token + * @returns {Promise>} 계정 목록 + */ +const fetchAdsenseAccounts = async (accessToken) => { + const response = await fetch(ADSENSE_ACCOUNTS_URL, { + headers: { + Authorization: `Bearer ${accessToken}` + } + }) + + const data = await response.json().catch(() => ({})) + + if (!response.ok) { + throw new Error(`AdSense 계정 조회 실패: ${data.error?.message || response.status}`) + } + + return Array.isArray(data.accounts) ? data.accounts : [] +} + +/** + * OAuth 승인 URL을 생성한다. + * @param {Object} options - 승인 URL 옵션 + * @param {string} options.clientId - OAuth 클라이언트 ID + * @param {string} options.redirectUri - 리다이렉트 URI + * @param {string} options.scope - OAuth scope + * @param {string} options.state - CSRF 방지용 state 값 + * @returns {string} 승인 URL + */ +const createAuthorizationUrl = ({ clientId, redirectUri, scope, state }) => { + const authorizationUrl = new URL(OAUTH_AUTHORIZE_URL) + + authorizationUrl.search = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope, + access_type: 'offline', + prompt: 'consent', + include_granted_scopes: 'true', + state + }).toString() + + return authorizationUrl.toString() +} + +loadEnvFile(process.env.ENV_FILE || '.env.development') + +const { clientId, clientSecret } = resolveOAuthClient() +const redirectPort = Number(process.env.GOOGLE_ADSENSE_OAUTH_PORT || DEFAULT_REDIRECT_PORT) +const redirectUri = `http://127.0.0.1:${redirectPort}/oauth2callback` +const scope = process.env.GOOGLE_ADSENSE_SCOPE || DEFAULT_SCOPE +const state = randomUUID() + +if (!clientId || !clientSecret) { + process.stderr.write( + [ + 'AdSense OAuth 클라이언트 정보가 없습니다.', + 'GOOGLE_ADSENSE_CLIENT_ID / GOOGLE_ADSENSE_CLIENT_SECRET을 env에 넣거나', + 'GOOGLE_ADSENSE_CREDENTIALS_PATH에 credentials.json 경로를 지정하세요.' + ].join('\n') + '\n' + ) + process.exit(1) +} + +process.stdout.write( + [ + 'AdSense refresh token 발급을 시작합니다.', + `Google Cloud OAuth 클라이언트의 승인된 리다이렉트 URI에 아래 주소가 필요합니다:`, + redirectUri, + '', + '브라우저에서 아래 URL을 열고 AdSense 계정으로 승인하세요:', + createAuthorizationUrl({ clientId, redirectUri, scope, state }), + '' + ].join('\n') +) + +const { code } = await waitForAuthorizationCode(redirectPort, state) +const tokenData = await exchangeAuthorizationCode({ + clientId, + clientSecret, + code, + redirectUri +}) + +if (!tokenData.refresh_token) { + process.stderr.write( + [ + 'refresh token이 응답에 없습니다.', + '이미 같은 앱에 권한을 부여한 적이 있으면 Google 계정 권한에서 앱 접근을 해제한 뒤 다시 실행하세요.' + ].join('\n') + '\n' + ) + process.exit(1) +} + +const accounts = tokenData.access_token + ? await fetchAdsenseAccounts(tokenData.access_token) + : [] + +process.stdout.write('\n.env.production에 아래 값을 추가하세요.\n\n') +process.stdout.write(`GOOGLE_ADSENSE_REFRESH_TOKEN=${tokenData.refresh_token}\n`) + +if (accounts.length > 0) { + process.stdout.write(`GOOGLE_ADSENSE_ACCOUNT_NAME=${accounts[0].name}\n`) + + if (accounts.length > 1) { + process.stdout.write('\n접근 가능한 AdSense 계정 목록:\n') + + for (const account of accounts) { + process.stdout.write(`- ${account.name}${account.displayName ? ` (${account.displayName})` : ''}\n`) + } + } +} else { + process.stdout.write('GOOGLE_ADSENSE_ACCOUNT_NAME=accounts/pub-게시자ID\n') + process.stdout.write('\n접근 가능한 AdSense 계정을 찾지 못했습니다. 게시자 ID 앞의 ca-를 빼고 accounts/를 붙여 입력하세요.\n') +}