AdSense 토큰 발급 스크립트 추가
This commit is contained in:
284
scripts/generate-adsense-token.js
Normal file
284
scripts/generate-adsense-token.js
Normal file
@@ -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('<p>AdSense OAuth 승인이 완료되었습니다. 터미널로 돌아가세요.</p>')
|
||||
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<Array<{ name?: string, displayName?: string }>>} 계정 목록
|
||||
*/
|
||||
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')
|
||||
}
|
||||
Reference in New Issue
Block a user