[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:
parent
7a6fee70c1
commit
34e5932fbc
@ -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 />
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user