[fix] solution/frontend,site: 랜딩 색인 해제 + 사이트맵 URL 을 canonical 과 일치 — 크롤러의 유일한 문을 연다

이 호스트에는 들어오는 링크가 없다. 크롤러가 발행 사이트를 찾는 경로는 사이트맵
하나뿐인데 그 문이 두 군데서 막혀 있었다.

- 랜딩('/')에 `noindex, nofollow` 가 박혀 있었다. 관리자 화면이라고 보고 걸어 둔
  것인데(7a6fee7 로 '/' 관문이 사라져 이제는 랜딩이다), nofollow 때문에
  랜딩→발행 사이트로 이어지는 발견 경로까지 함께 죽어 있었다.
- 사이트맵이 `/s/<slug>/` 를 담는데 페이지 canonical 은 `/s/<slug>` 다
  (shared/lib/slug.ts publishUrl). 서치콘솔은 제출 URL 을 전부 "대체 페이지"로
  분류한다 — 색인은 되는데 제출분 0건으로 보이는, 조용히 틀리는 종류다.

- frontend/index.html: robots 를 index,follow 로. description·canonical·og 추가.
  호스트는 적지 않고 Vite 가 빌드 때 `%VITE_PUBLISH_HOST%` 를 치환한다
  (compose 가 루트 SITE_PUBLIC_HOST 를 흘려보낸다) — 두 곳에 적으면 갈라진다
- site/scripts/prerender.ts: 사이트맵 loc 의 끝 슬래시 제거 + 오리진 루트를 첫 항목으로

남은 것: 랜딩 body 가 빈 SPA 셸이라 렌더링에 기댄다. 랜딩 프리렌더는 별건이다.

검증: tsc(solution/site) 통과 · vite build 로 %VITE_PUBLISH_HOST% 치환 확인

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fteiJNvAEbTnUKq8fSqoj
This commit is contained in:
Mina Choi 2026-09-07 09:07:36 +09:00
parent 7a6fee70c1
commit 34e5932fbc
2 changed files with 31 additions and 5 deletions

View File

@ -3,8 +3,25 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<!-- 관리자 화면은 색인될 이유가 없다. 발행 사이트만 색인되게 한다. --> <!--
<meta name="robots" content="noindex, nofollow" /> 2026-09-07: noindex 를 걷었다. 이 자리는 관리자 화면이 아니라 **랜딩**이고,
로그인 뒤 빌더로 갈릴 뿐이다. 색인을 막아 두는 동안 이 호스트에는 검색엔진이
들어올 문이 하나(sitemap)뿐이었고, nofollow 때문에 랜딩→발행 사이트로 이어지는
발견 경로도 함께 죽어 있었다.
★ body 가 빈 SPA 셸이라 렌더링에 기대야 한다 — 랜딩 프리렌더 전까지는 절반짜리다.
-->
<meta name="robots" content="index, follow, max-snippet:-1, max-image-preview:large" />
<meta name="description" content="네이버 플레이스만 있으면 검색·AI 답변에 인용되는 우리 가게 홈페이지가 만들어집니다. 주소·영업시간·메뉴가 구조화 데이터로 함께 나갑니다." />
<!-- ★ 호스트를 여기 적지 않는다. Vite 가 빌드 때 VITE_PUBLISH_HOST 로 치환한다
(compose 가 루트 SITE_PUBLIC_HOST 를 흘려보낸다) — 두 곳에 적으면 조용히 갈라진다. -->
<link rel="canonical" href="https://%VITE_PUBLISH_HOST%/" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Web4Ai" />
<meta property="og:locale" content="ko_KR" />
<meta property="og:title" content="Web4Ai · AI 웹 빌더" />
<meta property="og:description" content="네이버 플레이스만 있으면 검색·AI 답변에 인용되는 우리 가게 홈페이지가 만들어집니다." />
<meta property="og:url" content="https://%VITE_PUBLISH_HOST%/" />
<meta name="twitter:card" content="summary_large_image" />
<!-- self-host Pretendard 가 어떤 환경에서든 못 뜰 때 한글이 굴림/맑은고딕으로 떨어지지 않게 하는 안전망 --> <!-- self-host Pretendard 가 어떤 환경에서든 못 뜰 때 한글이 굴림/맑은고딕으로 떨어지지 않게 하는 안전망 -->
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

View File

@ -26,6 +26,7 @@ import {
renderLlmsTxt, renderLlmsTxt,
renderRootRobotsTxt, renderRootRobotsTxt,
renderSiteUrlset, renderSiteUrlset,
type SiteEntry,
verifyGeo, verifyGeo,
verifyJsonLd, verifyJsonLd,
} from '@/seo'; } from '@/seo';
@ -509,21 +510,29 @@ function writeRootMachineFiles(outRoot: string, origin: string) {
const sitesDir = join(outRoot, SITE_DIR); const sitesDir = join(outRoot, SITE_DIR);
if (!existsSync(sitesDir)) return; if (!existsSync(sitesDir)) return;
const entries = readdirSync(sitesDir, {withFileTypes: true}) const entries: SiteEntry[] = readdirSync(sitesDir, {withFileTypes: true})
.filter((entry) => entry.isDirectory()) .filter((entry) => entry.isDirectory())
.map((entry) => ({slug: entry.name, file: join(sitesDir, entry.name, 'index.html')})) .map((entry) => ({slug: entry.name, file: join(sitesDir, entry.name, 'index.html')}))
// index.html 이 없으면 발행이 끝나지 않은(또는 실패한) 디렉토리다. 사이트맵에 넣지 않는다. // index.html 이 없으면 발행이 끝나지 않은(또는 실패한) 디렉토리다. 사이트맵에 넣지 않는다.
.filter((entry) => existsSync(entry.file)) .filter((entry) => existsSync(entry.file))
.map((entry) => ({ .map((entry) => ({
loc: joinUrl(origin, SITE_DIR, entry.slug) + '/', // ★ 끝 슬래시를 붙이지 않는다. 페이지의 canonical 은 `/s/<slug>` 다(shared/lib/slug.ts
// publishUrl). 사이트맵이 `/s/<slug>/` 로 어긋나 있던 동안 서치콘솔은 제출한 URL 을
// 전부 "대체 페이지(적절한 표준 태그가 있음)" 로 분류했다 — 색인은 되는데 제출분은
// 0건으로 보이는, 눈으로 원인을 못 찾는 종류다.
loc: joinUrl(origin, SITE_DIR, entry.slug),
// 페이지는 그 사이트를 구울 때마다 다시 쓰인다 — 파일 mtime 이 곧 마지막 발행 시각이다. // 페이지는 그 사이트를 구울 때마다 다시 쓰인다 — 파일 mtime 이 곧 마지막 발행 시각이다.
lastmod: statSync(entry.file).mtime.toISOString(), lastmod: statSync(entry.file).mtime.toISOString(),
})) }))
.sort((a, b) => a.loc.localeCompare(b.loc)); .sort((a, b) => a.loc.localeCompare(b.loc));
// ★ 오리진 루트(랜딩)도 담는다. 이 호스트에 들어오는 링크가 없어 크롤러의 유일한 문이
// 사이트맵인데, 정작 그 문을 여는 첫 페이지가 빠져 있었다.
entries.unshift({loc: origin + '/'});
writeFileSync(join(outRoot, 'robots.txt'), renderRootRobotsTxt(origin), 'utf-8'); writeFileSync(join(outRoot, 'robots.txt'), renderRootRobotsTxt(origin), 'utf-8');
writeFileSync(join(outRoot, 'sitemap.xml'), renderSiteUrlset(entries), 'utf-8'); writeFileSync(join(outRoot, 'sitemap.xml'), renderSiteUrlset(entries), 'utf-8');
console.log(` ✓ 루트 robots.txt · sitemap.xml (사이트 ${entries.length}개)`); console.log(` ✓ 루트 robots.txt · sitemap.xml (랜딩 + 사이트 ${entries.length - 1}개)`);
writeIndexNowKey(outRoot); writeIndexNowKey(outRoot);
} }