Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions .github/env-manifest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -159,15 +159,24 @@
- name: GA4_SERVICE_ACCOUNT_JSON
kind: secret
stores: [github, vercel]
consumers: [.github/workflows/daily-ga-report.yml, .github/workflows/weekly-ga-report.yml, apps/web/src/modules/ga/config.ts]
impact: "GitHub는 GA 리포트가, Vercel은 관리자 대시보드 활성 사용자 그래프가 동작하지 않는다(연결 없음으로 표시)"
consumers: [.github/workflows/daily-ga-report.yml, .github/workflows/weekly-ga-report.yml, .github/workflows/seo-monitor.yml, apps/web/src/modules/ga/config.ts]
impact: "GitHub는 GA 리포트와 SEO Sheets 적재가, Vercel은 관리자 대시보드 활성 사용자 그래프가 동작하지 않는다(연결 없음으로 표시)"

- name: GA_SHEET_ID
kind: secret
stores: [github]
required: false
consumers: [.github/workflows/weekly-ga-report.yml]
impact: "주간 GA 수치가 Google Sheets에 쌓이지 않는다(Slack 리포트는 그대로 가고 경고만 남는다)"
note: "GA 전용 스프레드시트다. SEO 분석은 SEO_SHEET_ID 로 시트를 나눈다. URL의 /d/와 /edit 사이 ID를 등록하고 서비스 계정에 편집 권한을 준다"

- name: SEO_SHEET_ID
kind: secret
stores: [github]
required: false
consumers: [.github/workflows/seo-monitor.yml]
impact: "SEO·GSC 장기 이력이 Google Sheets에 쌓이지 않는다(공개 SEO 검사와 원본 아티팩트는 유지된다)"
note: "SEO 전용 스프레드시트다. GA 주간 수치(GA_SHEET_ID)와 나눠 쓴다"

- name: GSC_SERVICE_ACCOUNT_JSON
kind: secret
Expand Down
188 changes: 188 additions & 0 deletions .github/scripts/lib/google-sheets.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { exchangeServiceAccountToken } from "./google-auth.mjs";

/** 첫 컬럼을 고유 키로 사용하여 탭과 헤더를 보장하고 행을 멱등 저장합니다. 호출자는 동시 쓰기를 직렬화해야 합니다. */
export const upsertGoogleSheetTables = async ({
spreadsheetId,
serviceAccount,
tables,
fetcher = fetch,
tokenExchanger = exchangeServiceAccountToken,
sleep = (milliseconds) =>
new Promise((resolve) => setTimeout(resolve, milliseconds)),
}) => {
if (tables.length === 0) {
return { updatedRows: 0 };
}
validateTables(tables);
const accessToken = await tokenExchanger({
serviceAccount,
scope: "https://www.googleapis.com/auth/spreadsheets",
});
const endpoint = `https://sheets.googleapis.com/v4/spreadsheets/${encodeURIComponent(spreadsheetId)}`;
const request = async (path, body) =>
await requestSheets({
url: `${endpoint}${path}`,
body,
accessToken,
fetcher,
sleep,
});
const spreadsheet = await request("?fields=sheets.properties");
const propertiesByTitle = new Map(
(spreadsheet.sheets ?? []).map((sheet) => [
sheet.properties.title,
sheet.properties,
]),
);
const missing = tables.filter((table) => !propertiesByTitle.has(table.title));
if (missing.length > 0) {
const created = await request(":batchUpdate", {
requests: missing.map((table) => ({
addSheet: {
properties: {
title: table.title,
gridProperties: {
rowCount: Math.max(1000, table.rows.length + 1),
columnCount: Math.max(26, table.headers.length),
},
},
},
})),
});
for (const reply of created.replies ?? []) {
const properties = reply.addSheet?.properties;
if (properties) {
propertiesByTitle.set(properties.title, properties);
}
}
}
const query = new URLSearchParams();
for (const table of tables) {
query.append("ranges", `${quoteTitle(table.title)}!1:1`);
query.append("ranges", `${quoteTitle(table.title)}!A2:A`);
}
const existing = await request(`/values:batchGet?${query}`);
const data = [];
const resizeRequests = [];
let updatedRows = 0;
for (const [index, table] of tables.entries()) {
const header = existing.valueRanges?.[index * 2]?.values?.[0] ?? [];
if (
header.length > 0 &&
JSON.stringify(header) !== JSON.stringify(table.headers)
) {
throw new Error(`Google Sheets header mismatch: ${table.title}`);
}
if (header.length === 0) {
data.push({
range: `${quoteTitle(table.title)}!A1`,
values: [table.headers],
});
}
const keys = existing.valueRanges?.[index * 2 + 1]?.values ?? [];
const rowByKey = new Map();
for (const [offset, cells] of keys.entries()) {
if (cells[0] !== undefined && cells[0] !== "") {
const key = String(cells[0]);
if (rowByKey.has(key)) {
throw new Error(
`Google Sheets duplicate existing key: ${table.title}`,
);
}
rowByKey.set(key, offset + 2);
}
}
let nextRow = keys.length + 2;
for (const row of table.rows) {
const key = String(row[0]);
const rowNumber = rowByKey.get(key) ?? nextRow++;
rowByKey.set(key, rowNumber);
data.push({
range: `${quoteTitle(table.title)}!A${rowNumber}`,
values: [row],
});
updatedRows += 1;
}
const properties = propertiesByTitle.get(table.title);
if (
properties &&
(nextRow - 1 > properties.gridProperties.rowCount ||
table.headers.length > properties.gridProperties.columnCount)
) {
resizeRequests.push({
updateSheetProperties: {
properties: {
sheetId: properties.sheetId,
gridProperties: {
rowCount: Math.max(
nextRow - 1,
properties.gridProperties.rowCount,
),
columnCount: Math.max(
table.headers.length,
properties.gridProperties.columnCount,
),
},
},
fields: "gridProperties.rowCount,gridProperties.columnCount",
},
});
}
}
if (resizeRequests.length > 0) {
await request(":batchUpdate", { requests: resizeRequests });
}
if (data.length > 0) {
await request("/values:batchUpdate", { valueInputOption: "RAW", data });
}

return { updatedRows };
};

const quoteTitle = (title) => `'${title.replaceAll("'", "''")}'`;

const validateTables = (tables) => {
const titles = new Set();
for (const table of tables) {
if (!table.title || titles.has(table.title) || table.headers.length === 0) {
throw new Error(
"Google Sheets tables must have unique titles and headers",
);
}
titles.add(table.title);
const keys = new Set();
for (const row of table.rows) {
if (
!row[0] ||
keys.has(String(row[0])) ||
row.length !== table.headers.length
) {
throw new Error(
`Google Sheets invalid row or duplicate key: ${table.title}`,
);
}
keys.add(String(row[0]));
}
}
};

const requestSheets = async ({ url, body, accessToken, fetcher, sleep }) => {
for (let attempt = 0; attempt < 3; attempt += 1) {
const response = await fetcher(url, {
method: body ? "POST" : "GET",
headers: {
authorization: `Bearer ${accessToken}`,
"content-type": "application/json",
},
...(body ? { body: JSON.stringify(body) } : {}),
});
if (response.ok) {
return await response.json();
}
if ((response.status === 429 || response.status >= 500) && attempt < 2) {
await sleep(1000 * 2 ** attempt);
continue;
}
throw new Error(`Google Sheets API request failed (${response.status})`);
}
};
148 changes: 148 additions & 0 deletions .github/scripts/lib/google-sheets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { describe, expect, it, vi } from "vitest";
import { upsertGoogleSheetTables } from "./google-sheets.mjs";

const response = (body, status = 200) =>
new Response(JSON.stringify(body), { status });
const properties = {
title: "SEO Runs",
sheetId: 1,
gridProperties: { rowCount: 1000, columnCount: 26 },
};
const createOptions = () => ({
spreadsheetId: "spreadsheet",
serviceAccount: {},
tokenExchanger: vi.fn(async () => "token"),
sleep: vi.fn(async () => {}),
tables: [
{
title: "SEO Runs",
headers: ["key", "count"],
rows: [
["123:1", 3],
["124:1", 4],
],
},
],
});

describe("upsertGoogleSheetTables", () => {
it("없는 탭을 생성하고 헤더와 행을 RAW 배치로 저장한다", async () => {
const fetcher = vi
.fn()
.mockResolvedValueOnce(response({ sheets: [] }))
.mockResolvedValueOnce(
response({ replies: [{ addSheet: { properties } }] }),
)
.mockResolvedValueOnce(response({ valueRanges: [{}, {}] }))
.mockResolvedValueOnce(response({}));
const options = createOptions();
expect(await upsertGoogleSheetTables({ ...options, fetcher })).toEqual({
updatedRows: 2,
});
expect(options.tokenExchanger).toHaveBeenCalledWith({
serviceAccount: {},
scope: "https://www.googleapis.com/auth/spreadsheets",
});
expect(
JSON.parse(fetcher.mock.calls[1][1].body).requests[0].addSheet.properties
.title,
).toBe("SEO Runs");
expect(JSON.parse(fetcher.mock.calls[3][1].body)).toEqual({
valueInputOption: "RAW",
data: [
{ range: "'SEO Runs'!A1", values: [["key", "count"]] },
{ range: "'SEO Runs'!A2", values: [["123:1", 3]] },
{ range: "'SEO Runs'!A3", values: [["124:1", 4]] },
],
});
});
it("기존 키는 같은 행에 갱신하고 신규 키만 마지막에 추가한다", async () => {
const fetcher = vi
.fn()
.mockResolvedValueOnce(response({ sheets: [{ properties }] }))
.mockResolvedValueOnce(
response({
valueRanges: [
{ values: [["key", "count"]] },
{ values: [["old"], ["123:1"]] },
],
}),
)
.mockResolvedValueOnce(response({}));
await upsertGoogleSheetTables({ ...createOptions(), fetcher });
expect(JSON.parse(fetcher.mock.calls[2][1].body).data).toEqual([
{ range: "'SEO Runs'!A3", values: [["123:1", 3]] },
{ range: "'SEO Runs'!A4", values: [["124:1", 4]] },
]);
});
it("데이터가 그리드를 넘어가면 쓰기 전에 확장한다", async () => {
const fetcher = vi
.fn()
.mockResolvedValueOnce(
response({
sheets: [
{
properties: {
...properties,
gridProperties: { rowCount: 2, columnCount: 1 },
},
},
],
}),
)
.mockResolvedValueOnce(
response({
valueRanges: [{ values: [["key", "count"]] }, { values: [["old"]] }],
}),
)
.mockResolvedValueOnce(response({}))
.mockResolvedValueOnce(response({}));
await upsertGoogleSheetTables({ ...createOptions(), fetcher });
expect(
JSON.parse(fetcher.mock.calls[2][1].body).requests[0]
.updateSheetProperties.properties.gridProperties,
).toEqual({ rowCount: 4, columnCount: 2 });
});
it("헤더가 다르면 기존 데이터를 덮어쓰지 않는다", async () => {
const fetcher = vi
.fn()
.mockResolvedValueOnce(response({ sheets: [{ properties }] }))
.mockResolvedValueOnce(
response({ valueRanges: [{ values: [["unexpected"]] }, {}] }),
);
await expect(
upsertGoogleSheetTables({ ...createOptions(), fetcher }),
).rejects.toThrow("header mismatch");
expect(fetcher).toHaveBeenCalledTimes(2);
});
it("429와 5xx 오류를 지수 백오프로 재시도한다", async () => {
const fetcher = vi
.fn()
.mockResolvedValueOnce(response({}, 429))
.mockResolvedValueOnce(response({}, 503))
.mockResolvedValueOnce(response({ sheets: [{ properties }] }))
.mockResolvedValueOnce(response({ valueRanges: [{}, {}] }))
.mockResolvedValueOnce(response({}));
const options = createOptions();
await upsertGoogleSheetTables({ ...options, fetcher });
expect(options.sleep.mock.calls).toEqual([[1000], [2000]]);
});
it("재시도는 세 번까지이며 인증 오류는 재시도하지 않는다", async () => {
for (const status of [503, 403]) {
const fetcher = vi.fn(async () => response({}, status));
await expect(
upsertGoogleSheetTables({ ...createOptions(), fetcher }),
).rejects.toThrow(`(${status})`);
expect(fetcher).toHaveBeenCalledTimes(status === 503 ? 3 : 1);
}
});
it("입력 키가 중복되면 요청 전에 거절한다", async () => {
const options = createOptions();
options.tables[0].rows.push(["123:1", 5]);
const fetcher = vi.fn();
await expect(
upsertGoogleSheetTables({ ...options, fetcher }),
).rejects.toThrow("duplicate key");
expect(fetcher).not.toHaveBeenCalled();
});
});
Loading
Loading