From 4216a05fca4657442105fb8c8e17f076dafee38e Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Fri, 11 Sep 2026 14:08:48 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[fix]=20site:=20=EC=82=AC=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=EB=A7=B5=C2=B7=EB=AA=A9=EB=A1=9D=C2=B7llms.txt=20=EC=97=90?= =?UTF-8?q?=EC=84=9C=20noindex=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=A0=9C?= =?UTF-8?q?=EC=99=B8=20=E2=80=94=20=EB=AA=A9=EC=97=85=20=EB=B3=B5=EC=A0=9C?= =?UTF-8?q?=EB=B3=B8=EC=9D=B4=20/s/stay=20=EB=A5=BC=20=EA=B2=80=EC=83=89?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B0=80=EC=96=B4=EB=82=B8=20=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /s/stay 가 "스테이머뭄" 구글 검색에서 통째로 빠졌다. out/s/ 에 목업·백업(stay2 · stay3 · stay.old)이 운영본과 제목·본문이 같은 채(단어 87%) 각자 자기를 canonical 로 가리키고 있었고, 사이트맵·/s 목록이 디렉토리를 훑어 이들을 전부 구글에 제출했다 — 같은 글 여러 벌 중 구글이 하나만 고른다. 목업 셋은 운영 볼륨에서 noindex 로 바꿨고(2026-09-11), 이 커밋은 그런 페이지가 다시 제출되지 않게 한다. - seo/directory.ts: readBakedNoindex — 구운 HTML 의 robots 메타를 읽는다. 슬러그 이름(.old)으로 거르지 않는다 — 페이지 자신의 선언이 유일한 출처다 - prerender.ts writeRootMachineFiles: noindex 페이지를 사이트맵·/s 목록·루트 llms.txt 에서 뺀다. 남겨 두면 서치콘솔이 "제출된 URL 에 noindex" 를 계속 띄운다 vitest directory.test.ts 8 passed · tsc·eslint 통과 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CEQ9auj65yJKk2MnWbtRqU --- solution/site/scripts/prerender.ts | 9 ++++++--- solution/site/src/seo/directory.test.ts | 19 ++++++++++++++++++- solution/site/src/seo/directory.ts | 15 +++++++++++++++ 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/solution/site/scripts/prerender.ts b/solution/site/scripts/prerender.ts index d356bad..6807767 100644 --- a/solution/site/scripts/prerender.ts +++ b/solution/site/scripts/prerender.ts @@ -23,6 +23,7 @@ import { collectJsonLd, homeMeta, readBakedLastmod, + readBakedNoindex, readBakedTitle, renderHead, renderLlmsTxt, @@ -715,9 +716,11 @@ function writeRootMachineFiles(outRoot: string, origin: string) { .map((entry) => ({slug: entry.name, file: join(sitesDir, entry.name, 'index.html')})) // index.html 이 없으면 발행이 끝나지 않은(또는 실패한) 디렉토리다. 사이트맵에 넣지 않는다. .filter((entry) => existsSync(entry.file)) - .map((entry) => { - // 제목과 lastmod 가 같은 HTML 에서 나온다 — 파일은 한 번만 읽는다. - const html = readFileSync(entry.file, 'utf-8'); + // 제목·lastmod·noindex 가 같은 HTML 에서 나온다 — 파일은 한 번만 읽는다. + .map((entry) => ({...entry, html: readFileSync(entry.file, 'utf-8')})) + // ★ noindex 를 선언한 페이지(목업·백업)는 싣지 않는다 — readBakedNoindex 주석 참조. + .filter((entry) => !readBakedNoindex(entry.html)) + .map(({html, ...entry}) => { return { // ★ 끝 슬래시를 붙이지 않는다. 페이지의 canonical 은 `/s/` 다(shared/lib/slug.ts // publishUrl). 사이트맵이 `/s//` 로 어긋나 있던 동안 서치콘솔은 제출한 URL 을 diff --git a/solution/site/src/seo/directory.test.ts b/solution/site/src/seo/directory.test.ts index 958cedf..cee3056 100644 --- a/solution/site/src/seo/directory.test.ts +++ b/solution/site/src/seo/directory.test.ts @@ -10,7 +10,7 @@ import {describe, expect, it} from 'vitest'; import {MOONLIGHT_STAY_PAYLOAD} from '../fixtures/moonlight-stay'; -import {readBakedLastmod, readBakedTitle} from './directory'; +import {readBakedLastmod, readBakedNoindex, readBakedTitle} from './directory'; import {renderHead} from './head'; import {homeMeta} from './meta'; @@ -44,3 +44,20 @@ describe('readBakedTitle', () => { expect(readBakedTitle('')).toBe(''); }); }); + +describe('readBakedNoindex', () => { + it('발행본 head 는 index 다 — 사이트맵에 남는다', () => { + expect(readBakedNoindex(bakedHead())).toBe(false); + }); + + it('noindex 를 선언한 목업은 걸러진다', () => { + const mockup = + ''; + expect(readBakedNoindex(mockup)).toBe(true); + expect(readBakedNoindex('')).toBe(true); + }); + + it('robots 메타가 없으면 색인 대상으로 친다', () => { + expect(readBakedNoindex('x')).toBe(false); + }); +}); diff --git a/solution/site/src/seo/directory.ts b/solution/site/src/seo/directory.ts index 0fa9c87..67c8948 100644 --- a/solution/site/src/seo/directory.ts +++ b/solution/site/src/seo/directory.ts @@ -20,6 +20,21 @@ export function readBakedTitle(html: string): string { return match ? match[1].trim() : ''; } +/** + * 페이지가 스스로 `noindex` 를 선언했나. 그런 페이지는 사이트맵·`/s` 목록·llms.txt 에 싣지 않는다. + * + * ★ 왜 — `out/s/` 에는 payload 없는 목업·백업(`stay2` · `stay3` · `*.old`)이 섞여 있고, 목록은 + * 디렉토리를 훑어 만든다. 목업은 운영본(`/s/stay`)의 복제라 제목·본문이 같은데 canonical 은 + * 각자 자기를 가리켰다 — 구글은 같은 글 여러 벌 중 하나만 고르고, 실측(2026-09-11) 운영본이 + * "스테이머뭄" 검색에서 통째로 빠졌다. 목업에 noindex 를 박는 것만으로는 절반이다 — 사이트맵에 + * 남아 있으면 서치콘솔이 "제출된 URL 에 noindex" 오류를 계속 띄운다. + * ★ 슬러그 이름 규칙(`.old` 등)으로 거르지 않는다 — 페이지 자신의 선언이 유일한 출처다. + */ +export function readBakedNoindex(html: string): boolean { + const match = / Date: Fri, 11 Sep 2026 14:25:13 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[fix]=20postgres-init:=20=ED=9A=8C=EC=82=AC?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=200000=20=EC=B6=94=EA=B0=80=20=E2=80=94=20?= =?UTF-8?q?=ED=82=B9=EC=84=9C=EB=B2=84=20DB=20=EA=B0=80=200005=20=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=A7=89=ED=9E=88=EB=8D=98=20=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 회사(테넌트) 제거(94551af, 09-08)는 migrations 폴더가 생기기 전 변경이라 init.sql 의 DO 블록으로만 있었고, 09-10 init.sql 재작성 때 사라졌다. 로컬 DB 는 이미 따라와 있어 드러나지 않았다. 실측(킹서버 2026-09-11): schema_migrations 가 없는 옛 구조 DB 에서 0005 의 `DROP SCHEMA company RESTRICT` 가 company.companies(3행) 때문에 실패 — 0001~0010 이 하나도 못 들어간다. - 0000_drop_companies.sql: 94551af 의 블록 그대로 — owner_user_id 백필(회사의 가장 먼저 만든 계정) → 주인 없는 업장 삭제 → NOT NULL → places.company_id · users.company_id · companies 삭제. 0005 보다 앞이어야 해서 0000. 이미 전부 적용한 DB 에는 마지막에 돌므로 전부 존재 검사로 감쌌다 - README: 번호 규칙의 예외 한 줄 킹서버 덤프 복원본 리허설: 0000~0010 11건 적용, 업장 32곳 owner_null 0 · 삭제 0, 행 수 보존(사진 255 · 사실 86 · 객실 222 · 사이트 22), 적용된 DB 에 0000 재실행 무해. init.sql 로 세운 DB 와 스키마 비교 차이 1건(idx_local_contents_status) — 다음 커밋 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CEQ9auj65yJKk2MnWbtRqU --- .../migrations/0000_drop_companies.sql | 37 +++++++++++++++++++ postgres-init/migrations/README.md | 2 + 2 files changed, 39 insertions(+) create mode 100644 postgres-init/migrations/0000_drop_companies.sql diff --git a/postgres-init/migrations/0000_drop_companies.sql b/postgres-init/migrations/0000_drop_companies.sql new file mode 100644 index 0000000..d993cf8 --- /dev/null +++ b/postgres-init/migrations/0000_drop_companies.sql @@ -0,0 +1,37 @@ +-- 0000 · 회사(테넌트) 제거를 기존 DB 에 적용한다 — owner_user_id 백필 → company_id 삭제 → companies 삭제 +-- +-- ★ 왜 이 파일이 늦게 생겼나 (2026-09-11) +-- 회사 제거(94551af, 2026-09-08)는 이 폴더가 생기기(09-09) **전**의 변경이라 init.sql 안의 +-- DO 블록으로만 있었다. 09-10 init.sql 을 현재 모습으로 다시 쓰면서 그 블록이 사라졌고, +-- 로컬 DB 는 그 사이 init.sql 로 이미 따라와 있어서 아무도 몰랐다. +-- 실측(킹서버): 회사가 살아 있는 채로 남은 DB 는 0005 의 `DROP SCHEMA company RESTRICT` 에서 +-- 멈춘다(company.companies 3행) — 0001~0010 이 하나도 못 들어간다. +-- +-- ★ 왜 0000 인가 — 0005 보다 먼저 돌아야 하고, 시간 순으로도 폴더의 어떤 변경보다 앞이다. +-- 새 파일은 여전히 번호를 이어 붙인다(README). 이 번호는 이 파일 하나의 예외다. +-- +-- ★ 0001~0010 을 이미 적용한 DB 에는 이 파일이 **마지막에** 돈다(기록에 없으므로). +-- 그래서 전부 존재 검사로 감싼다 — 그런 DB 에는 place·company 스키마가 없어 아무것도 안 한다. +-- +-- ★ 규칙은 94551af 그대로다. 주인은 그 회사의 **가장 먼저 만든 계정**이고, 주인을 못 찾은 업장은 +-- 지운다 — 스코프가 없으면 아무에게도 안 보이는 유령이다. +-- 실측(킹서버 2026-09-11): 업장 32곳 모두 회사에 계정이 하나씩 있어 삭제 0건. + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema='place' AND table_name='places' AND column_name='company_id') THEN + UPDATE place.places p + SET owner_user_id = ( + SELECT u.user_id FROM company.users u + WHERE u.company_id = p.company_id AND u.deleted = FALSE + ORDER BY u.created_at LIMIT 1) + WHERE p.owner_user_id IS NULL; + DELETE FROM place.places WHERE owner_user_id IS NULL; + ALTER TABLE place.places ALTER COLUMN owner_user_id SET NOT NULL; + ALTER TABLE place.places DROP COLUMN company_id; + END IF; +END $$; + +ALTER TABLE IF EXISTS company.users DROP COLUMN IF EXISTS company_id; +DROP TABLE IF EXISTS company.companies; diff --git a/postgres-init/migrations/README.md b/postgres-init/migrations/README.md index 56b06e1..a3034a3 100644 --- a/postgres-init/migrations/README.md +++ b/postgres-init/migrations/README.md @@ -17,6 +17,8 @@ TourAPI 가 주변 정보를 받아 와도 저장할 곳이 없어 축제·맛 ## 규칙 - 파일명 `NNNN_한글_요약.sql` — 번호는 이어 붙인다. 지운 번호를 재사용하지 않는다. + ★ 예외는 `0000_drop_companies` 하나다 — 이 폴더가 생기기 전(09-08) 변경을 뒤늦게 옮긴 것이라 + 0005 보다 앞에 둔다. 이미 전부 적용한 DB 에는 마지막에 돌므로 존재 검사로 감싸 두었다(파일 머리주석). - **재실행 안전하게 쓴다**(`IF NOT EXISTS` · `ADD COLUMN IF NOT EXISTS`). 적용 기록이 있어도 사람이 손으로 한 번 더 돌릴 수 있다. - 한 파일 = 한 가지 변경. 여러 테이블을 건드려도 목적이 하나면 한 파일이다. From 29c1a1f462da4fe8978e587807454c030f6113d2 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Fri, 11 Sep 2026 14:25:43 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[fix]=20postgres-init:=20area=5Fcontents=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=200011=20?= =?UTF-8?q?=E2=80=94=20init.sql=20=EC=97=90=EB=A7=8C=20=EC=9E=88=EA=B3=A0?= =?UTF-8?q?=20=EC=98=9B=20DB=20=EC=97=90=20=EC=97=86=EB=8D=98=20=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 킹서버 덤프 복원본에 0000~0010 을 돌린 DB 와 init.sql 로 세운 DB 를 비교하니 컬럼·표는 같고 idx_local_contents_status 하나만 새 DB 에만 있었다. 서버 DB 가 이 인덱스보다 먼저 세워졌다. - 0011_area_contents_status_index.sql: init.sql 411행 정의 그대로, IF NOT EXISTS 검증: 적용 후 운영 DB 와 init.sql DB 스키마 비교로 확인한다(배포 절차에서) Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CEQ9auj65yJKk2MnWbtRqU --- .../migrations/0011_area_contents_status_index.sql | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 postgres-init/migrations/0011_area_contents_status_index.sql diff --git a/postgres-init/migrations/0011_area_contents_status_index.sql b/postgres-init/migrations/0011_area_contents_status_index.sql new file mode 100644 index 0000000..e903c2a --- /dev/null +++ b/postgres-init/migrations/0011_area_contents_status_index.sql @@ -0,0 +1,10 @@ +-- 0011 · area_contents 상태 조회 인덱스 — init.sql 에만 있고 옛 DB 에는 없던 것 +-- +-- ★ 어떻게 찾았나 (2026-09-11) +-- 킹서버 덤프 복원본에 0000~0010 을 돌린 DB 와 init.sql 로 새로 세운 DB 를 나란히 찍어 비교했다 +-- (0009 가 적은 방법 그대로). 컬럼·표는 전부 같았고 이 인덱스 하나만 새 DB 에만 있었다 — +-- 킹서버 DB 가 이 인덱스가 init.sql 에 들어가기 전에 세워졌기 때문이다. +-- ★ 동작에는 영향이 없다. 수집 상태로 area_contents 를 거를 때 시퀀셜 스캔이 될 뿐이다. + +CREATE INDEX IF NOT EXISTS idx_local_contents_status ON public.area_contents (status, collected_at DESC) + WHERE deleted = FALSE;