diff --git a/.github/env-manifest.yml b/.github/env-manifest.yml index e66b19f7a..4e41994d3 100644 --- a/.github/env-manifest.yml +++ b/.github/env-manifest.yml @@ -159,8 +159,8 @@ - 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 @@ -168,6 +168,15 @@ 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 diff --git a/.github/scripts/lib/google-sheets.mjs b/.github/scripts/lib/google-sheets.mjs new file mode 100644 index 000000000..bb6e37e6f --- /dev/null +++ b/.github/scripts/lib/google-sheets.mjs @@ -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})`); + } +}; diff --git a/.github/scripts/lib/google-sheets.test.ts b/.github/scripts/lib/google-sheets.test.ts new file mode 100644 index 000000000..f3c92b614 --- /dev/null +++ b/.github/scripts/lib/google-sheets.test.ts @@ -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(); + }); +}); diff --git a/.github/scripts/lib/seo-sheets.mjs b/.github/scripts/lib/seo-sheets.mjs new file mode 100644 index 000000000..4064faf87 --- /dev/null +++ b/.github/scripts/lib/seo-sheets.mjs @@ -0,0 +1,300 @@ +import { createSeoObservationMap } from "./seo-history.mjs"; +/** SEO/GSC 보고서를 분석용 시트 행과 수동 변경 기록 탭으로 변환합니다. 비율은 0~1이며 분모가 없으면 빈 셀입니다. */ +export const createSeoSheetTables = ({ + seoReport, + gscReport, + githubRunId, + githubRunAttempt, + commitSha = "", + runUrl = "", +}) => { + if (!githubRunId || !githubRunAttempt || !Array.isArray(seoReport?.pages)) { + throw new Error("SEO Sheets requires a run ID, attempt and SEO report"); + } + const runKey = `${githubRunId}:${githubRunAttempt}`; + const context = [ + runKey, + seoReport.generatedAt ?? "", + String(githubRunId), + String(githubRunAttempt), + ]; + const pageGroups = new Map(); + const observations = createSeoObservationMap(seoReport); + for (const page of seoReport.pages.filter((page) => page.kind === "page")) { + const pages = pageGroups.get(page.url) ?? []; + pages.push(page); + pageGroups.set(page.url, pages); + } + const groups = [...pageGroups.values()]; + const observedGroups = groups.filter((pages) => + pages.every((page) => + observations.get([page.kind, page.url, page.agent].join("\u001f")), + ), + ); + const errorPageCount = groups.filter((pages) => + pages.some((page) => + page.issues.some((issue) => issue.severity === "error"), + ), + ).length; + const warningPageCount = groups.filter((pages) => + pages.some((page) => + page.issues.some((issue) => issue.severity === "warning"), + ), + ).length; + const healthyPageCount = observedGroups.filter((pages) => + pages.every((page) => page.issues.length === 0), + ).length; + const ratio = (count) => (groups.length > 0 ? count / groups.length : ""); + const delta = seoReport.history?.delta ?? {}; + const baselineStatus = seoReport.history?.baselineStatus ?? "missing"; + const issueRows = []; + if (baselineStatus === "compatible") { + for (const state of ["new", "resolved", "unobservable"]) { + for (const issue of delta[state] ?? []) { + const issueKey = + issue.key ?? + JSON.stringify([ + issue.kind, + issue.url, + issue.agent, + issue.code, + issue.field, + ]); + issueRows.push([ + JSON.stringify([runKey, state, issueKey]), + ...context, + state, + issueKey, + issue.kind ?? "", + issue.url ?? "", + issue.agent ?? "", + issue.code ?? "", + issue.field ?? "", + issue.severity ?? "", + issue.message ?? "", + ]); + } + } + } + const hasGsc = gscReport && gscReport.status !== "skipped"; + const inspections = hasGsc ? (gscReport.inspections ?? []) : []; + const weekly = hasGsc ? gscReport.weekly : null; + const summary = { + key: runKey, + generatedAt: context[1], + githubRunId: context[2], + githubRunAttempt: context[3], + commitSha, + runUrl, + status: seoReport.errors > 0 ? "failed" : "passed", + requestCount: seoReport.pages.length, + pageCount: groups.length, + observedPageCount: observedGroups.length, + errorCount: seoReport.errors ?? 0, + warningCount: seoReport.warnings ?? 0, + errorPageCount, + warningPageCount, + healthyPageCount, + errorPageRatio: ratio(errorPageCount), + warningPageRatio: ratio(warningPageCount), + healthyPageRatio: ratio(healthyPageCount), + baselineStatus, + newCount: delta.new?.length ?? 0, + persistentCount: delta.persistent?.length ?? 0, + resolvedCount: delta.resolved?.length ?? 0, + unobservableCount: delta.unobservable?.length ?? 0, + gscStatus: gscReport?.status ?? "missing", + gscInspectionCount: inspections.length, + gscFailureCount: gscReport?.failures?.length ?? 0, + ...createWeeklySummary(weekly), + }; + + return [ + { + title: "SEO Runs", + headers: Object.keys(summary), + rows: [Object.values(summary)], + }, + { + title: "SEO Issue Events", + headers: [ + "key", + "runKey", + "generatedAt", + "githubRunId", + "githubRunAttempt", + "state", + "issueKey", + "kind", + "url", + "agent", + "code", + "field", + "severity", + "message", + ], + rows: issueRows, + }, + { + title: "GSC Index", + headers: [ + "key", + "runKey", + "generatedAt", + "githubRunId", + "githubRunAttempt", + "inspectedAt", + "siteUrl", + "url", + "verdict", + "coverageState", + "indexingState", + "robotsTxtState", + "pageFetchState", + "lastCrawlTime", + "googleCanonical", + "userCanonical", + ], + rows: inspections.map((inspection) => [ + JSON.stringify([runKey, inspection.url]), + ...context, + gscReport.generatedAt ?? "", + gscReport.siteUrl ?? "", + ...[ + "url", + "verdict", + "coverageState", + "indexingState", + "robotsTxtState", + "pageFetchState", + "lastCrawlTime", + "googleCanonical", + "userCanonical", + ].map((field) => inspection[field] ?? ""), + ]), + }, + { + title: "GSC Performance", + headers: [ + "key", + "runKey", + "generatedAt", + "githubRunId", + "githubRunAttempt", + "siteUrl", + "period", + "startDate", + "endDate", + "dimension", + "value", + "clicks", + "impressions", + "ctr", + "position", + ], + rows: createPerformanceRows({ + weekly, + context, + siteUrl: gscReport?.siteUrl ?? "", + }), + }, + { + title: "SEO Changes", + headers: [ + "key", + "date", + "commitSha", + "prUrl", + "summary", + "affectedUrls", + "notes", + ], + rows: [], + }, + ]; +}; + +const createWeeklySummary = (weekly) => { + const summary = {}; + for (const period of ["current", "previous"]) { + for (const metric of ["clicks", "impressions", "ctr", "position"]) { + summary[`${period}${metric[0].toUpperCase()}${metric.slice(1)}`] = + weekly?.[period]?.[metric] ?? ""; + } + } + for (const metric of ["clicks", "impressions", "ctr", "position"]) { + const current = weekly?.current?.[metric]; + const previous = weekly?.previous?.[metric]; + const isRatio = metric === "clicks" || metric === "impressions"; + let change = ""; + if (Number.isFinite(current) && Number.isFinite(previous)) { + if (!isRatio) { + change = current - previous; + } else if (previous !== 0) { + change = (current - previous) / previous; + } + } + summary[`${metric}Change${isRatio ? "Ratio" : "Amount"}`] = change; + } + + return summary; +}; + +const createPerformanceRows = ({ weekly, context, siteUrl }) => { + if (!weekly) { + return []; + } + const rows = []; + const add = ({ metric, period, dimension, startDate, endDate }) => { + if (!metric) { + return; + } + const value = metric.keys?.[0] ?? ""; + rows.push([ + JSON.stringify([ + context[0], + period, + startDate, + endDate, + dimension, + value, + ]), + ...context, + siteUrl, + period, + startDate, + endDate, + dimension, + value, + metric.clicks ?? 0, + metric.impressions ?? 0, + metric.ctr ?? 0, + metric.position ?? 0, + ]); + }; + for (const period of ["current", "previous"]) { + add({ + metric: weekly[period], + period, + dimension: "total", + startDate: weekly.week[period === "current" ? "start" : "previousStart"], + endDate: weekly.week[period === "current" ? "end" : "previousEnd"], + }); + } + for (const [dimension, metrics] of [ + ["query", weekly.topQueries], + ["page", weekly.topPages], + ]) { + for (const metric of metrics ?? []) { + add({ + metric, + period: "current", + dimension, + startDate: weekly.week.start, + endDate: weekly.week.end, + }); + } + } + + return rows; +}; diff --git a/.github/scripts/lib/seo-sheets.test.ts b/.github/scripts/lib/seo-sheets.test.ts new file mode 100644 index 000000000..86cbf2f6d --- /dev/null +++ b/.github/scripts/lib/seo-sheets.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it } from "vitest"; +import { createSeoSheetTables } from "./seo-sheets.mjs"; + +const createReport = () => ({ + generatedAt: "2026-09-22T00:17:00Z", + errors: 1, + warnings: 1, + pages: [ + { + kind: "page", + url: "https://example.com/a", + agent: "pc", + status: 200, + metadata: {}, + issues: [{ severity: "warning" }], + }, + { + kind: "page", + url: "https://example.com/a", + agent: "mobile", + status: 200, + metadata: {}, + issues: [], + }, + { + kind: "page", + url: "https://example.com/b", + agent: "pc", + status: null, + failure: "timeout", + issues: [{ severity: "error" }], + }, + { + kind: "robots", + url: "https://example.com/robots.txt", + agent: "pc", + status: 200, + issues: [], + }, + ], + history: { + baselineStatus: "compatible", + delta: { + new: [ + { + key: "issue-a", + url: "https://example.com/a", + code: "TITLE", + severity: "warning", + }, + ], + persistent: [{ key: "persistent" }], + resolved: [{ key: "resolved" }], + unobservable: [{ key: "unobservable" }], + }, + }, +}); + +const metadata = { + githubRunId: "123", + githubRunAttempt: "1", + commitSha: "sha", + runUrl: "https://github.com/run/123", +}; +const record = (table) => + Object.fromEntries( + table.headers.map((header, index) => [header, table.rows[0][index]]), + ); + +describe("createSeoSheetTables", () => { + it("고유 URL의 관측 및 오류 비율과 변화 이벤트만 생성한다", () => { + const tables = createSeoSheetTables({ + ...metadata, + seoReport: createReport(), + }); + expect(record(tables[0])).toMatchObject({ + key: "123:1", + requestCount: 4, + pageCount: 2, + observedPageCount: 1, + errorPageRatio: 0.5, + warningPageRatio: 0.5, + healthyPageRatio: 0, + persistentCount: 1, + gscStatus: "missing", + }); + expect(tables[1].rows.map((row) => row[5])).toEqual([ + "new", + "resolved", + "unobservable", + ]); + for (const table of tables) { + for (const row of table.rows) { + expect(row).toHaveLength(table.headers.length); + } + } + }); + it("최초 기준선은 이벤트를 만들지 않고 빈 분모는 0%로 오인하지 않는다", () => { + const seoReport = createReport(); + seoReport.history.baselineStatus = "missing"; + seoReport.pages = []; + const tables = createSeoSheetTables({ ...metadata, seoReport }); + expect(tables[1].rows).toEqual([]); + expect(record(tables[0])).toMatchObject({ + baselineStatus: "missing", + pageCount: 0, + observedPageCount: 0, + errorPageRatio: "", + healthyPageRatio: "", + }); + }); + it("GSC skipped 상태는 실행에 남기고 GSC 행은 생성하지 않는다", () => { + const tables = createSeoSheetTables({ + ...metadata, + seoReport: createReport(), + gscReport: { status: "skipped", inspections: [{ url: "ignored" }] }, + }); + expect(record(tables[0]).gscStatus).toBe("skipped"); + expect(tables[2].rows).toEqual([]); + expect(tables[3].rows).toEqual([]); + expect(record(tables[0])).toMatchObject({ + currentClicks: "", + previousClicks: "", + clicksChangeRatio: "", + positionChangeAmount: "", + }); + }); + it("색인 스냅샷과 기간이 구분되는 주간 전체 및 상위 지표를 저장한다", () => { + const metric = { + keys: [], + clicks: 3, + impressions: 10, + ctr: 0.3, + position: 4, + }; + const tables = createSeoSheetTables({ + ...metadata, + seoReport: createReport(), + gscReport: { + status: "passed", + generatedAt: "2026-09-22", + siteUrl: "https://example.com/", + inspections: [ + { + url: "https://example.com/a", + verdict: "PASS", + googleCanonical: "https://example.com/a", + }, + ], + weekly: { + week: { + start: "2026-09-12", + end: "2026-09-18", + previousStart: "2026-09-05", + previousEnd: "2026-09-11", + }, + current: metric, + previous: { ...metric, clicks: 2 }, + topQueries: [{ ...metric, keys: ["=SUM(A1)"] }], + topPages: [{ ...metric, keys: ["https://example.com/a"] }], + }, + }, + }); + expect(record(tables[2])).toMatchObject({ + verdict: "PASS", + googleCanonical: "https://example.com/a", + }); + expect(tables[3].rows.map((row) => row.slice(6, 12))).toEqual([ + ["current", "2026-09-12", "2026-09-18", "total", "", 3], + ["previous", "2026-09-05", "2026-09-11", "total", "", 2], + ["current", "2026-09-12", "2026-09-18", "query", "=SUM(A1)", 3], + [ + "current", + "2026-09-12", + "2026-09-18", + "page", + "https://example.com/a", + 3, + ], + ]); + expect(new Set(tables[3].rows.map((row) => row[0])).size).toBe(4); + expect(record(tables[0])).toMatchObject({ + currentClicks: 3, + previousClicks: 2, + currentImpressions: 10, + previousImpressions: 10, + currentCtr: 0.3, + previousCtr: 0.3, + currentPosition: 4, + previousPosition: 4, + clicksChangeRatio: 0.5, + impressionsChangeRatio: 0, + ctrChangeAmount: 0, + positionChangeAmount: 0, + }); + }); + it("수동 변경 기록 탭의 헤더만 만들고 자동 행을 추가하지 않는다", () => { + const tables = createSeoSheetTables({ + ...metadata, + seoReport: createReport(), + }); + expect(tables[4]).toEqual({ + title: "SEO Changes", + headers: [ + "key", + "date", + "commitSha", + "prUrl", + "summary", + "affectedUrls", + "notes", + ], + rows: [], + }); + }); + it("직전 기간 분모가 0이면 변화율을 비워 두고 CTR과 순위는 차이를 남긴다", () => { + const tables = createSeoSheetTables({ + ...metadata, + seoReport: createReport(), + gscReport: { + status: "passed", + weekly: { + week: { + start: "2026-09-12", + end: "2026-09-18", + previousStart: "2026-09-05", + previousEnd: "2026-09-11", + }, + current: { clicks: 5, impressions: 10, ctr: 0.5, position: 3 }, + previous: { clicks: 0, impressions: 0, ctr: 0, position: 5 }, + }, + }, + }); + expect(record(tables[0])).toMatchObject({ + clicksChangeRatio: "", + impressionsChangeRatio: "", + ctrChangeAmount: 0.5, + positionChangeAmount: -2, + }); + }); + it("동일 attempt는 동일 키, 다른 attempt는 다른 키를 생성한다", () => { + const input = { ...metadata, seoReport: createReport() }; + const tables = createSeoSheetTables(input); + expect(createSeoSheetTables(input)).toEqual(tables); + expect( + createSeoSheetTables({ ...input, githubRunAttempt: "2" })[1].rows[0][0], + ).not.toBe(tables[1].rows[0][0]); + }); +}); diff --git a/.github/scripts/persist-seo-sheets.mjs b/.github/scripts/persist-seo-sheets.mjs new file mode 100644 index 000000000..47704020c --- /dev/null +++ b/.github/scripts/persist-seo-sheets.mjs @@ -0,0 +1,116 @@ +import { appendFile, readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { upsertGoogleSheetTables } from "./lib/google-sheets.mjs"; +import { createSeoSheetTables } from "./lib/seo-sheets.mjs"; + +/** + * SEO 분석을 쌓을 Google Sheets 스프레드시트 ID 환경 변수입니다. + * @description GA 주간 수치(GA_SHEET_ID)와 시트를 나눕니다. 한 ID를 두 작업이 함께 쓰면 한쪽이 값을 바꿨을 때 다른 쪽이 조용히 남의 시트에 씁니다. + */ +export const SEO_SHEET_ID_ENV = "SEO_SHEET_ID"; + +/** + * SEO·GSC 보고서를 분석용 Google Sheets 행으로 멱등 적재합니다. + * @description 시트 설정이 없으면 공개 SEO 검사를 막지 않고 건너뜁니다. 설정이 있는데 API 적재가 실패하면 장기 이력 누락을 숨기지 않도록 예외를 전파합니다. + */ +export const persistSeoReportsToSheets = async ({ + spreadsheetId = process.env.SEO_SHEET_ID, + serviceAccountJson = process.env.GA4_SERVICE_ACCOUNT_JSON, + githubRunId = process.env.GITHUB_RUN_ID, + githubRunAttempt = process.env.GITHUB_RUN_ATTEMPT ?? "1", + commitSha = process.env.GITHUB_SHA ?? "", + runUrl = createGithubRunUrl(process.env), + stepSummaryPath = process.env.GITHUB_STEP_SUMMARY, +} = {}) => { + if (!spreadsheetId || !serviceAccountJson) { + const missingNames = [ + !spreadsheetId && SEO_SHEET_ID_ENV, + !serviceAccountJson && "GA4_SERVICE_ACCOUNT_JSON", + ].filter(Boolean); + const message = `Google Sheets 적재 건너뜀: ${missingNames.join(", ")} 없음`; + console.warn(`::warning::${message}`); + await appendStepSummary({ stepSummaryPath, message }); + + return { status: "skipped", updatedRows: 0 }; + } + if (!githubRunId) { + throw new Error("GITHUB_RUN_ID가 없어 Sheets 행의 멱등 키를 만들 수 없습니다."); + } + + const [seoReport, gscReport] = await Promise.all([ + readJson("artifacts/seo/seo-report.json"), + readOptionalJson("artifacts/seo/gsc-report.json"), + ]); + const serviceAccount = parseServiceAccount(serviceAccountJson); + const tables = createSeoSheetTables({ + seoReport, + gscReport, + githubRunId, + githubRunAttempt, + commitSha, + runUrl, + }); + const result = await upsertGoogleSheetTables({ + spreadsheetId, + serviceAccount, + tables, + }); + const message = `Google Sheets SEO 분석 행 ${result.updatedRows}건 적재`; + console.log(message); + await appendStepSummary({ stepSummaryPath, message }); + + return { status: "stored", ...result }; +}; + +const readJson = async (path) => JSON.parse(await readFile(path, "utf8")); + +const readOptionalJson = async (path) => { + try { + return await readJson(path); + } catch (error) { + if (error?.code === "ENOENT") { + return null; + } + + throw error; + } +}; + +const parseServiceAccount = (serviceAccountJson) => { + try { + const serviceAccount = JSON.parse(serviceAccountJson); + if (!serviceAccount.client_email || !serviceAccount.private_key) { + throw new Error("required fields missing"); + } + + return serviceAccount; + } catch { + throw new Error("GA4_SERVICE_ACCOUNT_JSON 형식이 올바르지 않습니다."); + } +}; + +const createGithubRunUrl = (environment) => { + const { GITHUB_SERVER_URL, GITHUB_REPOSITORY, GITHUB_RUN_ID } = environment; + if (!GITHUB_SERVER_URL || !GITHUB_REPOSITORY || !GITHUB_RUN_ID) { + return ""; + } + + return `${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}`; +}; + +const appendStepSummary = async ({ stepSummaryPath, message }) => { + if (!stepSummaryPath) { + return; + } + + await appendFile(stepSummaryPath, `\n## SEO 장기 이력\n\n${message}\n`); +}; + +if ( + process.argv[1] && + pathToFileURL(resolve(process.argv[1])).href === import.meta.url +) { + await persistSeoReportsToSheets(); +} diff --git a/.github/scripts/persist-seo-sheets.test.ts b/.github/scripts/persist-seo-sheets.test.ts new file mode 100644 index 000000000..d4a353807 --- /dev/null +++ b/.github/scripts/persist-seo-sheets.test.ts @@ -0,0 +1,103 @@ +// @ts-nocheck — .mjs 스크립트를 직접 import 하는 테스트라 타입 선언이 없습니다. +import { appendFile, readFile } from "node:fs/promises"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("node:fs/promises", () => ({ + appendFile: vi.fn(), + readFile: vi.fn(), +})); +vi.mock("./lib/seo-sheets.mjs", () => ({ + createSeoSheetTables: vi.fn(() => [ + { title: "SEO Runs", headers: ["key"], rows: [["1:1"]] }, + ]), +})); +vi.mock("./lib/google-sheets.mjs", () => ({ + upsertGoogleSheetTables: vi.fn(async () => ({ updatedRows: 1 })), +})); + +import { persistSeoReportsToSheets } from "./persist-seo-sheets.mjs"; +import { upsertGoogleSheetTables } from "./lib/google-sheets.mjs"; +import { createSeoSheetTables } from "./lib/seo-sheets.mjs"; + +afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); +}); + +describe("SEO Google Sheets 적재 CLI", () => { + it("설정이 없으면 검사를 실패시키지 않고 건너뛴다", async () => { + const result = await persistSeoReportsToSheets({ + spreadsheetId: "", + serviceAccountJson: "", + }); + + expect(result).toEqual({ status: "skipped", updatedRows: 0 }); + expect(readFile).not.toHaveBeenCalled(); + expect(upsertGoogleSheetTables).not.toHaveBeenCalled(); + }); + + it("보고서를 읽고 실행 키와 함께 멱등 적재한다", async () => { + readFile + .mockResolvedValueOnce(JSON.stringify({ generatedAt: "2026-09-22T00:00:00Z" })) + .mockResolvedValueOnce(JSON.stringify({ status: "passed" })); + const result = await persistSeoReportsToSheets({ + spreadsheetId: "sheet-id", + serviceAccountJson: JSON.stringify({ + client_email: "reporter@example.test", + private_key: "private-key", + }), + githubRunId: "123", + githubRunAttempt: "2", + commitSha: "abc", + runUrl: "https://github.test/run/123", + stepSummaryPath: "/tmp/summary.md", + }); + + expect(createSeoSheetTables).toHaveBeenCalledWith( + expect.objectContaining({ + githubRunId: "123", + githubRunAttempt: "2", + commitSha: "abc", + }), + ); + expect(upsertGoogleSheetTables).toHaveBeenCalledWith( + expect.objectContaining({ spreadsheetId: "sheet-id" }), + ); + expect(appendFile).toHaveBeenCalledWith( + "/tmp/summary.md", + expect.stringContaining("1건 적재"), + ); + expect(result).toEqual({ status: "stored", updatedRows: 1 }); + }); + + it("서비스 계정 JSON이 잘못되었으면 적재를 실패로 표시한다", async () => { + readFile.mockResolvedValue("{}"); + + await expect( + persistSeoReportsToSheets({ + spreadsheetId: "sheet-id", + serviceAccountJson: "{}", + githubRunId: "123", + }), + ).rejects.toThrow("GA4_SERVICE_ACCOUNT_JSON 형식"); + }); + + it("GSC 보고서가 없어도 SEO 요약은 적재한다", async () => { + readFile + .mockResolvedValueOnce(JSON.stringify({ generatedAt: "2026-09-22T00:00:00Z" })) + .mockRejectedValueOnce(Object.assign(new Error("not found"), { code: "ENOENT" })); + + await persistSeoReportsToSheets({ + spreadsheetId: "sheet-id", + serviceAccountJson: JSON.stringify({ + client_email: "reporter@example.test", + private_key: "private-key", + }), + githubRunId: "123", + }); + + expect(createSeoSheetTables).toHaveBeenCalledWith( + expect.objectContaining({ gscReport: null }), + ); + }); +}); diff --git a/.github/workflows/seo-monitor.yml b/.github/workflows/seo-monitor.yml index d14366cf0..56afce0e8 100644 --- a/.github/workflows/seo-monitor.yml +++ b/.github/workflows/seo-monitor.yml @@ -68,6 +68,13 @@ jobs: SLACK_REPORT_WEBHOOK_URL: ${{ secrets.SLACK_REPORT_WEBHOOK_URL }} run: node .github/scripts/notify-seo-slack.mjs + - name: Persist SEO analytics to Google Sheets + if: ${{ always() }} + env: + SEO_SHEET_ID: ${{ secrets.SEO_SHEET_ID }} + GA4_SERVICE_ACCOUNT_JSON: ${{ secrets.GA4_SERVICE_ACCOUNT_JSON }} + run: pnpm seo:sheets + - name: Upload SEO report if: ${{ always() }} uses: actions/upload-artifact@v6 diff --git a/docs/architecture.md b/docs/architecture.md index b2b660e2c..1296a7cd5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -23,6 +23,14 @@ | 모니터링 | Sentry (웹·확장·앱) | | i18n | ko/en. 웹은 `apps/web/src/modules/i18n/`(`util.client` / `util.server`), 확장은 `_locales/`. **`lng === "ko"` 분기를 쓰지 않고 항상 번역 키를 씁니다** | +### SEO 장기 이력 + +`seo-monitor.yml`은 원본 보고서를 Actions 아티팩트로 30일 보관하고, 실행 요약·이슈 +변경·GSC 색인·주간 성과는 `SEO_SHEET_ID`의 Google Sheets에 멱등 +적재합니다. Google Sheets 인증은 기존 `GA4_SERVICE_ACCOUNT_JSON`을 재사용하며, 해당 +서비스 계정은 대상 스프레드시트의 편집자여야 합니다. GSC 주간 성과는 +태평양 시간대의 월요일에 수집됩니다. + ## 프론트엔드 | 항목 | 내용 | diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 0f216c83f..8fb64593d 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -46,7 +46,7 @@ -### GitHub Secrets (23개) +### GitHub Secrets (24개) | 이름 | 없으면 생기는 일 | 읽는 곳 | | --- | --- | --- | @@ -59,11 +59,12 @@ | `EXPO_ASC_API_KEY_P8` | TestFlight 제출과 App Store 현황 조회가 실패한다 | `.github/workflows/cd-app.yml`, `.github/workflows/ci.yml`, `.github/workflows/versions.yml` | | `EXPO_TOKEN` | EAS 로그인이 실패해 앱 빌드가 멈춘다 | `.github/workflows/cd-app.yml` | | `GA_SHEET_ID` (선택) | 주간 GA 수치가 Google Sheets에 쌓이지 않는다(Slack 리포트는 그대로 가고 경고만 남는다) | `.github/workflows/weekly-ga-report.yml` | -| `GA4_SERVICE_ACCOUNT_JSON` | GitHub는 GA 리포트가, Vercel은 관리자 대시보드 활성 사용자 그래프가 동작하지 않는다(연결 없음으로 표시) | `.github/workflows/daily-ga-report.yml`, `.github/workflows/weekly-ga-report.yml`, `apps/web/src/modules/ga/config.ts` | +| `GA4_SERVICE_ACCOUNT_JSON` | GitHub는 GA 리포트와 SEO Sheets 적재가, Vercel은 관리자 대시보드 활성 사용자 그래프가 동작하지 않는다(연결 없음으로 표시) | `.github/workflows/daily-ga-report.yml`, `.github/workflows/weekly-ga-report.yml`, `.github/workflows/seo-monitor.yml`, `apps/web/src/modules/ga/config.ts` | | `GSC_SERVICE_ACCOUNT_JSON` (선택) | 없으면 공개 SEO 검사는 계속 실행되지만 Search Console 색인 상태와 주간 검색 성과 조회를 건너뛴다 | `.github/workflows/seo-monitor.yml` | | `NOTION_TOKEN` | 주간 리팩토링 점검 결과가 노션 작업 카드로 만들어지지 않는다 | `.github/workflows/refactor-audit.yml` | | `REFRESH_TOKEN` | 크롬 웹스토어 API 인증이 실패해 확장 배포와 스토어 현황 조회가 멈춘다 | `.github/workflows/cd-extension.yml`, `.github/workflows/ci.yml`, `.github/workflows/versions.yml` | | `SENTRY_AUTH_TOKEN` | Sentry 소스맵 업로드가 조용히 실패한다. 빌드는 통과하므로 스택 트레이스가 난독화된 채 보여야 알게 된다 | `.github/workflows/cd-extension.yml`, `apps/web/next.config.mjs`, `packages/vite-config/lib/withPageConfig.mjs` | +| `SEO_SHEET_ID` (선택) | SEO·GSC 장기 이력이 Google Sheets에 쌓이지 않는다(공개 SEO 검사와 원본 아티팩트는 유지된다) | `.github/workflows/seo-monitor.yml` | | `SLACK_BOT_TOKEN` | GitHub는 머지 스레드 생성과 댓글이, Vercel은 Slack 배포 모달이 동작하지 않는다 | `.github/workflows/ci.yml`, `apps/web/src/modules/slack/config.ts` | | `SLACK_CHANNEL_ID` | 머지 스레드가 생기지 않고 웹훅 알림으로 폴백한다 | `.github/workflows/ci.yml` | | `SLACK_REPORT_WEBHOOK_URL` | GA 리포트, 주간 리팩토링 점검 결과, 조치가 필요한 SEO 감사 결과가 전용 채널로 게시되지 않는다 | `.github/workflows/daily-ga-report.yml`, `.github/workflows/weekly-ga-report.yml`, `.github/workflows/refactor-audit.yml`, `.github/workflows/seo-monitor.yml` | @@ -81,7 +82,7 @@ | `BUILD_ENV` | production, preview | Git 연동 빌드가 development로 구워져 운영에 localhost:3000이 실린다. tsup.config.ts의 가드가 빌드를 실패시켜 막는다 | `packages/env/src/config.ts`, `packages/env/tsup.config.ts`, `apps/web/next.config.mjs`, `packages/zipper/index.ts` | | `ENABLE_EXPERIMENTAL_COREPACK` | 전체 | corepack이 꺼져 packageManager의 pnpm 버전이 무시된다 | 코드 밖 | | `GA4_PROPERTY_ID` (선택) | 전체 | 없으면 코드에 적힌 기본 속성 ID로 동작한다 | `apps/web/src/modules/ga/config.ts` | -| `GA4_SERVICE_ACCOUNT_JSON` | 전체 | GitHub는 GA 리포트가, Vercel은 관리자 대시보드 활성 사용자 그래프가 동작하지 않는다(연결 없음으로 표시) | `.github/workflows/daily-ga-report.yml`, `.github/workflows/weekly-ga-report.yml`, `apps/web/src/modules/ga/config.ts` | +| `GA4_SERVICE_ACCOUNT_JSON` | 전체 | GitHub는 GA 리포트와 SEO Sheets 적재가, Vercel은 관리자 대시보드 활성 사용자 그래프가 동작하지 않는다(연결 없음으로 표시) | `.github/workflows/daily-ga-report.yml`, `.github/workflows/weekly-ga-report.yml`, `.github/workflows/seo-monitor.yml`, `apps/web/src/modules/ga/config.ts` | | `GITHUB_DISPATCH_REPOSITORY` (선택) | 전체 | 없으면 guesung/Web-Memo로 동작한다 | `apps/web/src/modules/slack/config.ts` | | `GITHUB_DISPATCH_TOKEN` | 전체 | Slack에서 release.yml과 versions.yml을 실행하지 못한다 | `apps/web/src/modules/slack/config.ts` | | `NEXT_PUBLIC_CHANNEL_TALK_PLUGIN_KEY` | 전체 | 채널톡 위젯이 뜨지 않는다 | `apps/web/src/components/ChannelTalk/index.tsx` | @@ -430,6 +431,13 @@ Protocol로 직접 이벤트를 보내는 현재 구조상 이미 번들에 인 GA4 콘솔 → 관리 → 속성 설정 상단의 **숫자** 속성 ID이며, `packages/shared/src/constants/Analytics.ts`의 `G-6HHNP7KJM5`는 측정 ID라 Data API에 넣으면 403/404가 납니다. +`GA_SHEET_ID`는 GA·SEO 장기 이력을 같이 쌓는 Google Sheets URL의 `/d/`와 `/edit` +사이 ID입니다. 스프레드시트를 `GA4_SERVICE_ACCOUNT_JSON`의 `client_email`에 편집자로 +공유하고, 해당 서비스 계정의 GCP 프로젝트에서 Google Sheets API를 켜야 합니다. +탭은 없으면 스크립트가 만듭니다. 동일한 실행 ID와 시도 번호로 적재를 재시도하면 +기존 행을 갱신하고, GitHub Actions에서 재실행하여 시도 번호가 바뀌면 새 행을 남깁니다. +값이 없으면 원본 Actions 아티팩트는 남기고 Sheets 적재만 건너뜁니다. + ### `SENTRY_AUTH_TOKEN`은 확장과 웹이 서로 다른 경로로 받습니다 같은 이름이지만 공급처가 둘로 갈립니다. diff --git a/package.json b/package.json index be025c7bf..e20e7ce85 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "knip": "knip", "seo:check": "node .github/scripts/check-seo.mjs", "seo:gsc": "node .github/scripts/check-gsc.mjs", + "seo:sheets": "node .github/scripts/persist-seo-sheets.mjs", "dev": "turbo watch dev --filter !@web-memo/app", "dev-server": "pnpm -F hmr ready && pnpm -F hmr dev", "dev:web": "turbo watch dev --filter @web-memo/web",