Files

71 lines
7.0 KiB
JavaScript

'use strict';
// ============================================================================
// 구글 검색 등록 자동화 (v1.3.10)
//
// 사람이 Search Console 화면에서 하던 일을 구글 공식 API 로 대신한다. 필요한 것은 관리자가 한 번 넣는
// "서비스 계정 키(JSON)" 하나 (관리자 콘솔 › 연동 키 › 구글 검색 등록 자동화). 이 키로
// 1) Site Verification API : 사이트 확인 토큰(meta 태그) 받기 → (서비스가 태그를 넣고 재배포) → 소유 확인
// 2) Search Console API : 속성(사이트) 추가 → 사이트맵 제출
// 를 모두 자동으로 한다. 확인이 끝나면 서비스 계정이 사이트 소유자가 되며, OWNER_EMAIL(관리자 구글 계정)을
// 소유자로 함께 넣어 관리자가 Search Console 에서도 볼 수 있게 한다.
//
// 인증: 서비스 계정 JWT(RS256) → OAuth 토큰 (외부 라이브러리 없이 crypto 로 서명). 토큰은 만료 1분 전까지 재사용.
// 시험용 대체 주소: GOOGLE_TOKEN_URL(토큰), GOOGLE_API_BASE(https://www.googleapis.com 대신)
// ============================================================================
const crypto = require('crypto');
const SCOPES = ['https://www.googleapis.com/auth/siteverification', 'https://www.googleapis.com/auth/webmasters'];
const API = () => (process.env.GOOGLE_API_BASE || 'https://www.googleapis.com').replace(/\/+$/, '');
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
const b64u = (b) => Buffer.from(b).toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
let cache = { key: '', token: '', exp: 0 }; // 토큰 캐시 (같은 키일 때만)
/** 키 JSON 해석 (보관소의 secret 문자열) */
function parseKey(secret) { let j = null; try { j = JSON.parse(secret || ''); } catch (_) {} if (!j || !j.client_email || !j.private_key) throw httpError('구글 서비스 계정 키가 없거나 형식이 올바르지 않습니다 (관리자 콘솔 › 연동 키 › 구글 검색 등록 자동화)', 409); return j; }
const accountOf = (secret) => { try { return parseKey(secret).client_email; } catch (_) { return ''; } };
/** 서비스 계정으로 OAuth 액세스 토큰 받기 */
async function accessToken(secret, scopes) { // v1.5.36: scopes 를 주면 그 범위로 (Cloud DNS 등), 없으면 검색 등록 범위
const SC = Array.isArray(scopes) && scopes.length ? scopes : SCOPES;
const k = parseKey(secret); const id = k.client_email + k.private_key_id + '|' + SC.join(' ');
if (cache.key === id && cache.exp - 60 > Date.now() / 1000) return cache.token;
const now = Math.floor(Date.now() / 1000); const aud = process.env.GOOGLE_TOKEN_URL || k.token_uri || 'https://oauth2.googleapis.com/token';
const head = b64u(JSON.stringify({ alg: 'RS256', typ: 'JWT', kid: k.private_key_id })); const body = b64u(JSON.stringify({ iss: k.client_email, scope: SC.join(' '), aud, iat: now, exp: now + 3600 }));
let sig; try { sig = b64u(crypto.sign('RSA-SHA256', Buffer.from(`${head}.${body}`), k.private_key)); } catch (e) { throw httpError(`서비스 계정 키의 private_key 로 서명하지 못했습니다: ${e.message}`, 409); }
let r; try { r = await fetch(aud, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: `${head}.${body}.${sig}` }), signal: AbortSignal.timeout(20000) }); } catch (e) { throw httpError(`구글 인증 서버에 연결하지 못했습니다: ${e.cause ? e.cause.code || e.message : e.message}`, 502); }
const j = await r.json().catch(() => ({}));
if (!r.ok || !j.access_token) throw httpError(`구글 인증 실패 (${r.status} ${j.error || ''}${j.error_description ? ': ' + j.error_description : ''}) — 키가 폐기되었거나 서비스 계정이 비활성일 수 있습니다`, 502);
cache = { key: id, token: j.access_token, exp: now + (j.expires_in || 3600) }; return j.access_token;
}
/** 구글 API 호출 (JSON). 오류는 사람이 읽을 수 있는 안내로 바꾼다 */
async function call(secret, method, pathname, body) {
const tok = await accessToken(secret);
let r; try { r = await fetch(API() + pathname, { method, headers: { Authorization: `Bearer ${tok}`, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(30000) }); } catch (e) { throw httpError(`구글 API 에 연결하지 못했습니다: ${e.cause ? e.cause.code || e.message : e.message}`, 502); }
const text = await r.text(); let j = {}; try { j = text ? JSON.parse(text) : {}; } catch (_) {}
if (!r.ok) {
const msg = (j.error && (j.error.message || j.error.status)) || text.slice(0, 200);
const hint = r.status === 403 && /has not been used|disabled|SERVICE_DISABLED/i.test(msg) ? ' — Google Cloud 콘솔에서 이 프로젝트의 "Site Verification API" 와 "Google Search Console API" 를 사용 설정하세요' : r.status === 400 && /verif/i.test(msg) ? ' — 운영 첫 페이지에 확인 태그가 아직 보이지 않습니다(재배포 반영을 기다린 뒤 다시 시도)' : r.status === 403 ? ' — 서비스 계정에 이 사이트 권한이 없습니다(소유 확인을 먼저 끝내야 합니다)' : '';
throw Object.assign(httpError(`구글 API 오류 ${r.status}: ${msg}${hint}`, 502), { googleStatus: r.status });
}
return j;
}
/** 1) 사이트 확인 토큰 (meta 태그의 content 값) */
async function verificationToken(secret, siteUrl) {
const j = await call(secret, 'POST', '/siteVerification/v1/token', { site: { type: 'SITE', identifier: siteUrl }, verificationMethod: 'META' });
const m = String(j.token || '').match(/content=["']([^"']+)["']/); if (!m) throw httpError('구글이 확인 토큰을 주지 않았습니다', 502); return m[1];
}
/** 2) 소유 확인 (운영 첫 페이지에 태그가 있어야 성공). ownerEmail 이 있으면 소유자로 함께 등록 */
async function verify(secret, siteUrl, ownerEmail) {
const res = await call(secret, 'POST', '/siteVerification/v1/webResource?verificationMethod=META', { site: { type: 'SITE', identifier: siteUrl } });
if (ownerEmail && res.id && !(res.owners || []).includes(ownerEmail)) {
try { await call(secret, 'PUT', `/siteVerification/v1/webResource/${encodeURIComponent(res.id)}`, { site: res.site || { type: 'SITE', identifier: siteUrl }, owners: [...new Set([...(res.owners || []), ownerEmail])] }); res.ownerAdded = ownerEmail; }
catch (e) { res.ownerError = e.message; }
}
return res;
}
/** 3) Search Console 속성 추가 + 사이트맵 제출 */
async function addSite(secret, siteUrl) { await call(secret, 'PUT', `/webmasters/v3/sites/${encodeURIComponent(siteUrl)}`); return true; }
async function submitSitemap(secret, siteUrl, sitemapUrl) { await call(secret, 'PUT', `/webmasters/v3/sites/${encodeURIComponent(siteUrl)}/sitemaps/${encodeURIComponent(sitemapUrl)}`); return true; }
module.exports = { accessToken, accountOf, verificationToken, verify, addSite, submitSitemap, parseKey };