Files
googlesheet/sheet/googlesheetapi.ts
2026-05-12 22:37:36 +09:00

738 lines
18 KiB
TypeScript

import { mkdir } from "node:fs/promises";
const GOOGLE_AUTH_SCOPE = "https://www.googleapis.com/auth/spreadsheets";
const TOKEN_PATH = ".tokens/google-oauth-token.json";
const DEFAULT_REDIRECT_PORT = 3487;
const CALLBACK_PATH = "/oauth2callback";
const DEFAULT_TARGET_SHEET_URL =
"https://docs.google.com/spreadsheets/d/1LGF93SGkrIqqniY-a3ReTPhizsEMtgm5QBC8tSALmVM/edit?gid=0#gid=0";
export const TARGET_HEADERS = [
"id",
"created_time",
"campaign_name",
"form_name",
"platform",
"full_name",
"phone_number",
"site",
"created_at",
"activation_channel",
"customer_group",
"sales_point",
"contact_number",
"primary_product",
"status",
"key",
] as const;
export type TargetHeader = (typeof TARGET_HEADERS)[number];
export type SheetRow = Record<
string,
string | number | boolean | null | undefined
>;
type TokenResponse = {
access_token: string;
expires_in: number;
refresh_token?: string;
scope: string;
token_type: string;
};
type SavedToken = {
access_token: string;
expiry_date: number;
refresh_token: string;
scope: string;
token_type: string;
};
type SpreadsheetMetadata = {
sheets?: Array<{
properties?: {
sheetId?: number;
title?: string;
};
}>;
};
type SheetValuesResponse = {
range?: string;
majorDimension?: string;
values?: string[][];
};
type ValueRangePayload = {
range: string;
majorDimension: "ROWS";
values: string[][];
};
type BatchUpdateSpreadsheetRequest = {
requests: Array<{
addSheet?: {
properties?: {
title?: string;
};
};
}>;
};
type AppendRowsOptions = {
rows: SheetRow[];
sheetName: string;
baseDate?: string;
targetSheetUrl?: string;
};
const TARGET_HEADER_ALIASES: Record<TargetHeader, string[]> = {
id: ["id"],
created_time: ["created_time"],
campaign_name: ["campaign_name", "campain_name"],
form_name: ["form_name"],
platform: ["platform"],
full_name: ["full_name", "고객명"],
phone_number: ["phone_number", "핸드폰연락처"],
site: ["site"],
created_at: ["created_at", "접수일자"],
activation_channel: ["activation_channel", "개통처"],
customer_group: ["customer_group", "고객그룹"],
sales_point: ["sales_point", "판매점"],
contact_number: ["contact_number", "phone_number", "핸드폰연락처"],
primary_product: ["primary_product", "1차 상품"],
status: ["status", "진행상태"],
key: ["key", "고객키"],
};
function readRequiredEnv(name: string): string {
const value = Bun.env[name]?.trim();
if (!value) {
throw new Error(`${name} 환경변수가 필요합니다.`);
}
return value;
}
function parseSpreadsheetUrl(input: string) {
const url = new URL(input);
const match = url.pathname.match(/\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/);
if (!match?.[1]) {
throw new Error("유효한 구글 시트 URL이 아닙니다.");
}
const spreadsheetId = match[1];
const gidValue =
url.searchParams.get("gid") ?? url.hash.match(/gid=(\d+)/)?.[1] ?? null;
const gid = gidValue ? Number(gidValue) : null;
if (gidValue && Number.isNaN(gid)) {
throw new Error("gid 값을 해석하지 못했습니다.");
}
return { spreadsheetId, gid };
}
function escapeSheetTitle(title: string): string {
return `'${title.replaceAll("'", "''")}'`;
}
function normalizeHeader(header: string): string {
return header.trim().toLowerCase().replaceAll(/\s+/g, "_");
}
function stringifyCellValue(value: SheetRow[string]): string {
if (value == null) {
return "";
}
return String(value).trim();
}
function formatKstDateTime(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
return "";
}
// Keep date-only values as-is so KST conversion does not introduce 09:00:00.
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
return trimmed;
}
const date = new Date(trimmed);
if (Number.isNaN(date.getTime())) {
return value;
}
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Seoul",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
})
.formatToParts(date)
.reduce<Record<string, string>>((acc, part) => {
if (part.type !== "literal") {
acc[part.type] = part.value;
}
return acc;
}, {});
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}`;
}
function formatPhoneNumber(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
return "";
}
const normalized = trimmed.replace(/^p:/i, "").replace(/[^\d+]/g, "");
let digits = normalized;
if (digits.startsWith("+82")) {
digits = `0${digits.slice(3)}`;
} else if (digits.startsWith("82")) {
digits = `0${digits.slice(2)}`;
}
const phoneDigits = digits.replace(/\D/g, "");
if (phoneDigits.length === 10 && phoneDigits.startsWith("10")) {
return `010-${phoneDigits.slice(2, 6)}-${phoneDigits.slice(6)}`;
}
if (phoneDigits.length === 11) {
return `${phoneDigits.slice(0, 3)}-${phoneDigits.slice(3, 7)}-${phoneDigits.slice(7)}`;
}
return value;
}
function normalizeCellValue(header: string, value: string): string {
const normalizedHeader = normalizeHeader(header);
if (
normalizedHeader === "created_time" ||
normalizedHeader === "created_at"
) {
return formatKstDateTime(value);
}
if (
normalizedHeader === "phone_number" ||
normalizedHeader === "contact_number"
) {
return formatPhoneNumber(value);
}
return value;
}
function buildHeaderLookup(headers: string[]): Map<string, string> {
const lookup = new Map<string, string>();
headers.forEach((header, index) => {
const trimmed = header.trim() || `column_${index + 1}`;
lookup.set(trimmed, trimmed);
lookup.set(normalizeHeader(trimmed), trimmed);
});
return lookup;
}
function getRowValueForTarget(
row: SheetRow,
targetHeader: TargetHeader
): string {
const directValue = stringifyCellValue(row[targetHeader]);
if (directValue) {
return normalizeCellValue(targetHeader, directValue);
}
const lookup = buildHeaderLookup(Object.keys(row));
const mappedSourceHeader =
lookup.get(targetHeader) ?? lookup.get(normalizeHeader(targetHeader));
if (mappedSourceHeader) {
const mappedValue = stringifyCellValue(row[mappedSourceHeader]);
if (mappedValue) {
return normalizeCellValue(targetHeader, mappedValue);
}
}
const aliases = TARGET_HEADER_ALIASES[targetHeader] ?? [];
for (const alias of aliases) {
const aliasValue = stringifyCellValue(row[alias]);
if (aliasValue) {
return normalizeCellValue(targetHeader, aliasValue);
}
const aliasHeader = lookup.get(alias) ?? lookup.get(normalizeHeader(alias));
if (aliasHeader) {
const headerValue = stringifyCellValue(row[aliasHeader]);
if (headerValue) {
return normalizeCellValue(targetHeader, headerValue);
}
}
}
return "";
}
export function mapRowsForTarget(rows: SheetRow[]): string[][] {
return rows.map((row) =>
TARGET_HEADERS.map((header) => getRowValueForTarget(row, header))
);
}
async function readSavedToken(): Promise<SavedToken | null> {
const tokenFile = Bun.file(TOKEN_PATH);
if (!(await tokenFile.exists())) {
return null;
}
return (await tokenFile.json()) as SavedToken;
}
async function saveToken(token: SavedToken) {
await mkdir(".tokens", { recursive: true });
await Bun.write(TOKEN_PATH, JSON.stringify(token, null, 2));
}
async function refreshAccessToken(
clientId: string,
clientSecret: string,
refreshToken: string
): Promise<SavedToken> {
const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
grant_type: "refresh_token",
refresh_token: refreshToken,
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`토큰 갱신 실패: ${response.status} ${text}`);
}
const token = (await response.json()) as TokenResponse;
return {
access_token: token.access_token,
expiry_date: Date.now() + token.expires_in * 1000,
refresh_token: refreshToken,
scope: token.scope,
token_type: token.token_type,
};
}
async function requestNewToken(
clientId: string,
clientSecret: string
): Promise<SavedToken> {
const redirectPort = Number(
Bun.env.GOOGLE_REDIRECT_PORT ?? DEFAULT_REDIRECT_PORT
);
const redirectUri = `http://127.0.0.1:${redirectPort}${CALLBACK_PATH}`;
const state = crypto.randomUUID();
const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth");
authUrl.searchParams.set("client_id", clientId);
authUrl.searchParams.set("redirect_uri", redirectUri);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("scope", GOOGLE_AUTH_SCOPE);
authUrl.searchParams.set("access_type", "offline");
authUrl.searchParams.set("prompt", "consent");
authUrl.searchParams.set("state", state);
let resolveCode!: (value: string) => void;
let rejectCode!: (reason?: unknown) => void;
const codePromise = new Promise<string>((resolve, reject) => {
resolveCode = resolve;
rejectCode = reject;
});
const server = Bun.serve({
port: redirectPort,
fetch(request) {
const url = new URL(request.url);
if (url.pathname !== CALLBACK_PATH) {
return new Response("Not found", { status: 404 });
}
const returnedState = url.searchParams.get("state");
const code = url.searchParams.get("code");
const error = url.searchParams.get("error");
if (error) {
rejectCode(new Error(`OAuth 인증 실패: ${error}`));
return new Response(
"OAuth 인증이 취소되었습니다. 터미널을 확인해 주세요.",
{
status: 400,
}
);
}
if (returnedState !== state || !code) {
rejectCode(new Error("OAuth 콜백 검증에 실패했습니다."));
return new Response("잘못된 OAuth 콜백입니다.", { status: 400 });
}
resolveCode(code);
return new Response("인증이 완료되었습니다. 터미널로 돌아가 주세요.");
},
});
console.log("브라우저에서 Google OAuth 인증을 진행해 주세요.");
console.log(authUrl.toString());
try {
Bun.file(authUrl.toString());
} catch {
// URL은 이미 출력했으니 open 실패는 무시합니다.
}
let code: string;
try {
code = await Promise.race([
codePromise,
new Promise<string>((_, reject) => {
setTimeout(
() => reject(new Error("OAuth 인증 대기 시간이 초과되었습니다.")),
5 * 60 * 1000
);
}),
]);
} finally {
server.stop(true);
}
const response = await fetch("https://oauth2.googleapis.com/token", {
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,
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`OAuth 토큰 발급 실패: ${response.status} ${text}`);
}
const token = (await response.json()) as TokenResponse;
if (!token.refresh_token) {
throw new Error(
"refresh_token 을 받지 못했습니다. OAuth 클라이언트 설정을 확인해 주세요."
);
}
return {
access_token: token.access_token,
expiry_date: Date.now() + token.expires_in * 1000,
refresh_token: token.refresh_token,
scope: token.scope,
token_type: token.token_type,
};
}
async function getAuthorizedToken(): Promise<SavedToken> {
const clientId = readRequiredEnv("GOOGLE_CLIENT_ID");
const clientSecret = readRequiredEnv("GOOGLE_CLIENT_SECRET");
const savedToken = await readSavedToken();
const hasWriteScope = savedToken?.scope?.includes(GOOGLE_AUTH_SCOPE) ?? false;
if (savedToken?.refresh_token && hasWriteScope) {
const needsRefresh = savedToken.expiry_date <= Date.now() + 60_000;
if (!needsRefresh) {
return savedToken;
}
const refreshed = await refreshAccessToken(
clientId,
clientSecret,
savedToken.refresh_token
);
await saveToken(refreshed);
return refreshed;
}
if (savedToken?.refresh_token && !hasWriteScope) {
console.log(
"기존 토큰이 읽기 전용 scope 입니다. 브라우저에서 다시 OAuth 인증을 진행합니다."
);
}
const newToken = await requestNewToken(clientId, clientSecret);
await saveToken(newToken);
return newToken;
}
async function googleApiFetch<T>(accessToken: string, url: string): Promise<T> {
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Google API 호출 실패: ${response.status} ${text}`);
}
return (await response.json()) as T;
}
async function googleApiRequest<T>(
accessToken: string,
url: string,
init?: RequestInit
): Promise<T> {
const response = await fetch(url, {
...init,
headers: {
Authorization: `Bearer ${accessToken}`,
...(init?.headers ?? {}),
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Google API 호출 실패: ${response.status} ${text}`);
}
return (await response.json()) as T;
}
async function getSheetValues(
accessToken: string,
spreadsheetId: string,
range: string
): Promise<SheetValuesResponse> {
const encodedRange = encodeURIComponent(range);
return googleApiFetch<SheetValuesResponse>(
accessToken,
`https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${encodedRange}`
);
}
async function updateSheetValues(
accessToken: string,
spreadsheetId: string,
range: string,
values: string[][]
) {
const encodedRange = encodeURIComponent(range);
const body: ValueRangePayload = {
range,
majorDimension: "ROWS",
values,
};
return googleApiRequest(
accessToken,
`https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${encodedRange}?valueInputOption=USER_ENTERED`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
}
);
}
async function appendSheetValues(
accessToken: string,
spreadsheetId: string,
range: string,
values: string[][]
) {
const encodedRange = encodeURIComponent(range);
const body: ValueRangePayload = {
range,
majorDimension: "ROWS",
values,
};
return googleApiRequest(
accessToken,
`https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${encodedRange}:append?valueInputOption=USER_ENTERED&insertDataOption=INSERT_ROWS`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
}
);
}
async function hasSheetTitle(
accessToken: string,
spreadsheetId: string,
sheetTitle: string
): Promise<boolean> {
const metadata = await googleApiFetch<SpreadsheetMetadata>(
accessToken,
`https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}?fields=sheets.properties(title)`
);
return (
metadata.sheets?.some((sheet) => sheet.properties?.title === sheetTitle) ??
false
);
}
async function createSheet(
accessToken: string,
spreadsheetId: string,
sheetTitle: string
) {
const body: BatchUpdateSpreadsheetRequest = {
requests: [
{
addSheet: {
properties: {
title: sheetTitle,
},
},
},
],
};
return googleApiRequest(
accessToken,
`https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}:batchUpdate`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
}
);
}
async function ensureSheetExists(
accessToken: string,
spreadsheetId: string,
sheetTitle: string
) {
const exists = await hasSheetTitle(accessToken, spreadsheetId, sheetTitle);
if (!exists) {
await createSheet(accessToken, spreadsheetId, sheetTitle);
}
}
async function ensureTargetHeaderRow(
accessToken: string,
spreadsheetId: string,
sheetTitle: string
) {
const headerRange = `${escapeSheetTitle(sheetTitle)}!1:1`;
const current = await getSheetValues(accessToken, spreadsheetId, headerRange);
const existingHeaders = current.values?.[0] ?? [];
const hasSameHeaders =
existingHeaders.length === TARGET_HEADERS.length &&
TARGET_HEADERS.every((header, index) => existingHeaders[index] === header);
if (!hasSameHeaders) {
await updateSheetValues(accessToken, spreadsheetId, headerRange, [
Array.from(TARGET_HEADERS),
]);
}
}
function getTodayKstDateString(): string {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Seoul",
year: "numeric",
month: "2-digit",
day: "2-digit",
})
.formatToParts(new Date())
.reduce<Record<string, string>>((acc, part) => {
if (part.type !== "literal") {
acc[part.type] = part.value;
}
return acc;
}, {});
return `${parts.year}-${parts.month}-${parts.day}`;
}
function getMonthDayString(baseDate = getTodayKstDateString()): string {
return baseDate.slice(5).replace("-", "");
}
export async function appendRowsToDailySheet({
rows,
sheetName,
baseDate,
targetSheetUrl = Bun.env.GOOGLE_TARGET_SHEET_URL ?? DEFAULT_TARGET_SHEET_URL,
}: AppendRowsOptions) {
const targetSheetTitle = `${sheetName}${getMonthDayString(baseDate)}`;
if (rows.length === 0) {
return {
appendedRows: 0,
targetSheetTitle,
};
}
const token = await getAuthorizedToken();
const targetSheet = parseSpreadsheetUrl(targetSheetUrl);
const mappedRows = mapRowsForTarget(rows);
if (mappedRows.length === 0) {
return {
appendedRows: 0,
targetSheetTitle,
};
}
await ensureSheetExists(
token.access_token,
targetSheet.spreadsheetId,
targetSheetTitle
);
await ensureTargetHeaderRow(
token.access_token,
targetSheet.spreadsheetId,
targetSheetTitle
);
await appendSheetValues(
token.access_token,
targetSheet.spreadsheetId,
escapeSheetTitle(targetSheetTitle),
mappedRows
);
return {
appendedRows: mappedRows.length,
targetSheetTitle,
targetSpreadsheetId: targetSheet.spreadsheetId,
};
}