Skip to content

Commit 30ede08

Browse files
authored
Merge pull request #187 from devy1540/codex/search-console
fix: prevent broken internal post links
2 parents 98d54cf + 859b62a commit 30ede08

7 files changed

Lines changed: 365 additions & 2 deletions

‎content/posts/ko/spring-ai-guide-02-multi-provider.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ public BedrockProxyChatModel bedrockChatModel() {
115115
}
116116
```
117117

118-
`DefaultCredentialsProvider`는 AWS의 기본 인증 체인(환경변수, EC2 인스턴스 프로파일, ECS 태스크 역할 등)을 따른다. 로컬에서는 `~/.aws/credentials`, 배포 환경에서는 IAM 역할을 자동으로 사용한다. 타임아웃 설정은 4편에서 다룬다.
118+
`DefaultCredentialsProvider`는 AWS의 기본 인증 체인(환경변수, EC2 인스턴스 프로파일, ECS 태스크 역할 등)을 따른다. 로컬에서는 `~/.aws/credentials`, 배포 환경에서는 IAM 역할을 자동으로 사용한다. 연결 및 소켓 타임아웃 설정은 [Spring AI 실전 적용기](/posts/spring-ai-pipeline-real-world/)에서 다룬다.
119119

120120
## 프로바이더별 옵션 제어
121121

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Search Console 색인 생성 분류 기록
2+
3+
확인일: 2026-07-12
4+
5+
## 확인된 현황
6+
7+
- 색인 생성됨: 111개
8+
- 색인 생성되지 않음: 239개
9+
- 제출된 사이트맵: `https://devy1540.dev/sitemap.xml` (성공, 72개 페이지 발견)
10+
11+
| 우선순위 | 항목 | 근거 | 조치 |
12+
| --- | --- | --- | --- |
13+
| P0 | 미발행 Spring AI 4편 내부 링크 | `/posts/spring-ai-guide-04-production`이 404이고 현재 글에서 링크됨 | `spring-ai-pipeline-real-world` 글 링크로 교체 완료 |
14+
| P1 | 크롤링/발견됐지만 미색인 13개 | Search Console의 `크롤링됨` 8개와 `발견됨` 5개 | URL 목록을 내보내어 콘텐츠별 검토 |
15+
| P2 | 다른 표준 URL을 선택한 중복 31개 | Google 표준 선택 보고서 | 예시 URL의 canonical 및 내부 링크 대조 |
16+
| 모니터링 | 리디렉션 108개, canonical 대체 83개 | trailing slash·태그 쿼리 URL | 현재 301/canonical 동작을 유지 |
17+
18+
## 404 세부 항목
19+
20+
- `/posts/spring-ai-guide-04-production`: 내부 Markdown 링크가 원인이었으며 수정했다.
21+
- `/cdn-cgi/l/email-protection`: 현재 404이나 블로그 콘텐츠에서 직접 링크하는지 추가 확인이 필요하다.
22+
23+
## 비범위
24+
25+
- Google Search Console의 색인 생성 요청 또는 수정 결과 확인 버튼 실행
26+
- 호스팅/CDN 계층의 5xx 원인 수정
27+
- 미발행 Spring AI 4편 작성
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
# Search Console 내부 링크 무결성 Implementation Plan
2+
3+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (\`- [ ]\`) syntax for tracking.
4+
5+
**Goal:** 미발행 Spring AI 4편 링크를 실제 글로 교체하고, 존재하지 않는 \`/posts/:slug\` Markdown 내부 링크를 CI 또는 로컬에서 검출한다.
6+
7+
**Architecture:** \`scripts/check-internal-post-links.mjs\`는 Markdown 파일명에서 게시글 slug 집합을 만들고, Markdown 링크 목적지의 \`/posts/:slug\`만 비교한다. 순수 검사 함수를 내보내 Node 내장 테스트로 검증하고, CLI 실행은 \`package.json\` 스크립트로 노출한다.
8+
9+
**Tech Stack:** Node.js ESM, Node 내장 \`node:test\`, TypeScript/Vite 빌드, Markdown 콘텐츠.
10+
11+
## Global Constraints
12+
13+
- 외부 링크, 앵커 링크, 쿼리 문자열, \`/posts/\` 이외의 내부 라우트는 검사하지 않는다.
14+
- 누락된 링크는 파일 경로와 원래 링크 대상 전체를 모두 출력하고 종료 코드 1로 실패한다.
15+
- 기존 미추적 \`AGENTS.md\`는 스테이징하거나 수정하지 않는다.
16+
- Search Console에서 확인된 리디렉션·canonical 제외 항목을 코드로 자동 수정하지 않는다.
17+
18+
---
19+
20+
### Task 1: Markdown 게시글 링크 검사기와 회귀 테스트
21+
22+
**Files:**
23+
24+
- Create: \`scripts/check-internal-post-links.mjs\`
25+
- Create: \`scripts/check-internal-post-links.test.mjs\`
26+
- Modify: \`package.json:6-12\`
27+
28+
**Interfaces:**
29+
30+
- Consumes: \`content/posts/*.md\`의 파일명과 Markdown 본문.
31+
- Produces: \`findMissingPostLinks(posts)\` 및 \`npm run check:internal-links\`.
32+
33+
- [ ] **Step 1: 누락 slug를 찾는 실패 테스트 작성**
34+
35+
\`\`\`js
36+
import test from "node:test"
37+
import assert from "node:assert/strict"
38+
import { findMissingPostLinks } from "./check-internal-post-links.mjs"
39+
40+
test("존재하지 않는 게시글 slug를 내부 링크 오류로 보고한다", () => {
41+
const missing = findMissingPostLinks([
42+
{ path: "content/posts/existing.md", content: "[누락 글](/posts/not-published)" },
43+
{ path: "content/posts/other.md", content: "[존재 글](/posts/existing)" },
44+
])
45+
46+
assert.deepEqual(missing, [{
47+
path: "content/posts/existing.md",
48+
href: "/posts/not-published",
49+
slug: "not-published",
50+
}])
51+
})
52+
\`\`\`
53+
54+
- [ ] **Step 2: 실패를 확인한다**
55+
56+
Run: \`node --test scripts/check-internal-post-links.test.mjs\`
57+
58+
Expected: \`ERR_MODULE_NOT_FOUND\` 또는 \`findMissingPostLinks\` export 누락으로 실패한다.
59+
60+
- [ ] **Step 3: 최소 검사기를 구현한다**
61+
62+
\`\`\`js
63+
import fs from "node:fs"
64+
import path from "node:path"
65+
import { fileURLToPath } from "node:url"
66+
67+
const postLinkPattern = /\[[^\]]*\]\(\/posts\/([^/?#)]+)(?:[?#][^)]*)?\)/g
68+
69+
export function findMissingPostLinks(posts) {
70+
const slugs = new Set(posts.map((post) => path.basename(post.path, ".md")))
71+
const missing = []
72+
73+
for (const post of posts) {
74+
for (const match of post.content.matchAll(postLinkPattern)) {
75+
const slug = match[1]
76+
if (!slugs.has(slug)) missing.push({
77+
path: post.path,
78+
href: match[0].slice(match[0].lastIndexOf("(") + 1, -1),
79+
slug,
80+
})
81+
}
82+
}
83+
84+
return missing
85+
}
86+
87+
function run() {
88+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
89+
const postsDir = path.join(root, "content/posts")
90+
const posts = fs.readdirSync(postsDir)
91+
.filter((file) => file.endsWith(".md"))
92+
.map((file) => {
93+
const filePath = path.join(postsDir, file)
94+
return { path: path.relative(root, filePath), content: fs.readFileSync(filePath, "utf8") }
95+
})
96+
const missing = findMissingPostLinks(posts)
97+
98+
if (missing.length > 0) {
99+
console.error("Missing internal post links:")
100+
for (const item of missing) console.error("- " + item.path + ": " + item.href)
101+
process.exitCode = 1
102+
return
103+
}
104+
105+
console.log("Validated " + posts.length + " posts with no missing internal post links.")
106+
}
107+
108+
if (process.argv[1] === fileURLToPath(import.meta.url)) run()
109+
\`\`\`
110+
111+
Add this \`package.json\` script:
112+
113+
\`\`\`json
114+
"check:internal-links": "node scripts/check-internal-post-links.mjs"
115+
\`\`\`
116+
117+
- [ ] **Step 4: 테스트 통과를 확인한다**
118+
119+
Run: \`node --test scripts/check-internal-post-links.test.mjs\`
120+
121+
Expected: 1 test passed, 0 failed.
122+
123+
- [ ] **Step 5: 실제 콘텐츠 전체를 검사한다**
124+
125+
Run: \`npm run check:internal-links\`
126+
127+
Expected: 현재 4편 링크 때문에 \`spring-ai-guide-02-multi-provider.md: /posts/spring-ai-guide-04-production\`을 출력하고 종료 코드 1로 실패한다.
128+
129+
- [ ] **Step 6: 커밋한다**
130+
131+
\`\`\`bash
132+
git add scripts/check-internal-post-links.mjs scripts/check-internal-post-links.test.mjs package.json
133+
git commit -m "test: detect missing internal post links"
134+
\`\`\`
135+
136+
### Task 2: Spring AI 4편 404 링크 수정
137+
138+
**Files:**
139+
140+
- Modify: \`content/posts/spring-ai-guide-02-multi-provider.md:118\`
141+
142+
**Interfaces:**
143+
144+
- Consumes: \`spring-ai-pipeline-real-world.md\` slug와 Bedrock 타임아웃 설정 설명.
145+
- Produces: 존재하는 글만 가리키는 Spring AI 멀티 프로바이더 글.
146+
147+
- [ ] **Step 1: 수정 전 실패 상태를 재현한다**
148+
149+
Run: \`npm run check:internal-links\`
150+
151+
Expected: \`spring-ai-guide-02-multi-provider.md\`의 \`/posts/spring-ai-guide-04-production\` 누락 링크 때문에 실패한다.
152+
153+
- [ ] **Step 2: 링크를 실제 타임아웃 설명 글로 변경한다**
154+
155+
118행의 문장을 아래로 변경한다.
156+
157+
\`\`\`md
158+
\`DefaultCredentialsProvider\`는 AWS의 기본 인증 체인(환경변수, EC2 인스턴스 프로파일, ECS 태스크 역할 등)을 따른다. 로컬에서는 \`~/.aws/credentials\`, 배포 환경에서는 IAM 역할을 자동으로 사용한다. 연결 및 소켓 타임아웃 설정은 [Spring AI 실전 적용기](/posts/spring-ai-pipeline-real-world/)에서 다룬다.
159+
\`\`\`
160+
161+
- [ ] **Step 3: 링크 검사 통과를 확인한다**
162+
163+
Run: \`npm run check:internal-links\`
164+
165+
Expected: 종료 코드 0과 \`no missing internal post links\` 메시지.
166+
167+
- [ ] **Step 4: 전체 품질 검증을 실행한다**
168+
169+
Run: \`npm run type-check && npm run lint && npm run build && git diff --check\`
170+
171+
Expected: 모든 명령이 종료 코드 0으로 완료된다.
172+
173+
- [ ] **Step 5: 커밋한다**
174+
175+
\`\`\`bash
176+
git add content/posts/spring-ai-guide-02-multi-provider.md
177+
git commit -m "fix: replace missing Spring AI post link"
178+
\`\`\`
179+
180+
### Task 3: Search Console 후속 점검 기록
181+
182+
**Files:**
183+
184+
- Create: \`docs/search-console/2026-07-12-triage.md\`
185+
186+
**Interfaces:**
187+
188+
- Consumes: Search Console 페이지 색인 보고서의 원인별 수와 확인된 URL.
189+
- Produces: 다음 진단 시 재사용할 수 있는 조치 우선순위 기록.
190+
191+
- [ ] **Step 1: 조치 항목을 기록한다**
192+
193+
\`\`\`md
194+
| 우선순위 | 항목 | 근거 | 조치 |
195+
| --- | --- | --- | --- |
196+
| P0 | 미발행 Spring AI 4편 내부 링크 | \`/posts/spring-ai-guide-04-production\`이 404이고 현재 글에서 링크됨 | 실제 운영 글 링크로 교체 |
197+
| P1 | 크롤링/발견됐지만 미색인 13개 | Search Console의 \`크롤링됨\` 8개와 \`발견됨\` 5개 | URL 목록을 내보내어 콘텐츠별 검토 |
198+
| P2 | 다른 표준 URL을 선택한 중복 31개 | Google 표준 선택 보고서 | 예시 URL의 canonical 및 내부 링크 대조 |
199+
| 모니터링 | 리디렉션 108개, canonical 대체 83개 | trailing slash·태그 쿼리 URL | 현재 301/canonical 동작을 유지 |
200+
\`\`\`
201+
202+
- [ ] **Step 2: 변경 범위를 확인한다**
203+
204+
Run: \`git diff --check && git status --short\`
205+
206+
Expected: 링크 검사기, 콘텐츠 링크, 진단 문서만 추적 대상 변경으로 보이며 기존 \`AGENTS.md\`는 미추적으로 유지된다.
207+
208+
- [ ] **Step 3: 커밋한다**
209+
210+
\`\`\`bash
211+
git add docs/search-console/2026-07-12-triage.md
212+
git commit -m "docs: record Search Console triage"
213+
\`\`\`
214+
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Search Console 내부 링크 무결성 설계
2+
3+
## 목표
4+
5+
존재하지 않는 블로그 글을 가리키는 내부 링크를 제거하고, 이후 Markdown 글의 `/posts/:slug` 링크가 실제 게시 글과 일치하는지 빌드 전에 검증한다.
6+
7+
## 범위
8+
9+
- `spring-ai-guide-02-multi-provider.md`의 미발행 4편 링크를, 실제 타임아웃 설정을 다루는 `spring-ai-pipeline-real-world` 글 링크로 바꾼다.
10+
- `content/posts/*.md`의 상대 사이트 내부 `/posts/:slug` 링크만 검사한다.
11+
- 외부 링크, 앵커 링크, 쿼리 문자열, 다른 라우트 및 배포 인프라의 리디렉션은 검사 대상이 아니다.
12+
13+
## 설계
14+
15+
### 링크 수정
16+
17+
Spring AI 멀티 프로바이더 글은 Bedrock 타임아웃 설정의 후속 설명으로 실제 존재하는 `spring-ai-pipeline-real-world` 글을 참조한다. 이 글에는 `BedrockProxyChatModel`의 연결 및 소켓 타임아웃 설정이 포함돼 있어 원래 문맥과 일치한다.
18+
19+
### 링크 검사기
20+
21+
Node ESM 스크립트가 `content/posts`에서 Markdown 파일명을 읽어 유효 slug 집합을 만든다. 각 Markdown 본문에서 Markdown 링크 목적지 중 `/posts/:slug` 형식만 수집하고, slug가 집합에 없으면 파일명과 링크를 출력하고 비정상 종료한다.
22+
23+
검사기는 인수 없이 실행되며, 성공 시 검사한 파일 수와 내부 글 링크 수를 출력한다. `package.json`의 `check:internal-links` 스크립트로 노출하고, `npm run build` 전에 독립적으로 실행할 수 있게 한다.
24+
25+
## 오류 처리
26+
27+
- 누락된 slug가 하나라도 있으면 모든 누락 항목을 출력하고 종료 코드 1을 반환한다.
28+
- 게시글 디렉터리를 읽지 못하면 원본 오류를 표시하고 종료한다.
29+
30+
## 검증
31+
32+
1. 존재하지 않는 `/posts/__missing-post__` 링크를 포함한 임시 Markdown 파일로 검사기가 실패하는지 확인한다.
33+
2. 실제 문서의 링크만 남긴 뒤 검사기가 통과하는지 확인한다.
34+
3. 타입 검사, lint, production build를 실행한다.
35+
36+
## 비범위
37+
38+
- Search Console에서 발견한 리디렉션·canonical 제외 URL의 자동 교정
39+
- 새 Spring AI 4편 작성
40+
- CDN 또는 호스팅 제공자의 5xx 원인 수정
41+
- Google 색인 요청 또는 Search Console의 검증 버튼 실행

‎package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
"build": "tsc -b && vite build && vite build --ssr src/entry-server.tsx --outDir dist-ssr && node scripts/prerender.mjs",
99
"preview": "vite preview",
1010
"lint": "eslint .",
11-
"type-check": "tsc --noEmit"
11+
"type-check": "tsc --noEmit",
12+
"check:internal-links": "node scripts/check-internal-post-links.mjs"
1213
},
1314
"dependencies": {
1415
"@giscus/react": "^3.1.0",
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import fs from "node:fs"
2+
import path from "node:path"
3+
import { fileURLToPath } from "node:url"
4+
5+
const postLinkPattern = new RegExp(
6+
String.raw`\[[^\]]*\]\(/posts/([^/?#)]+)/?(?:[?#][^)]*)?\)`,
7+
"g",
8+
)
9+
10+
export function findMissingPostLinks(posts) {
11+
const slugs = new Set(posts.map((post) => path.basename(post.path, ".md")))
12+
const missing = []
13+
14+
for (const post of posts) {
15+
for (const match of post.content.matchAll(postLinkPattern)) {
16+
const href = match[0].slice(match[0].lastIndexOf("(") + 1, -1)
17+
const slug = match[1]
18+
19+
if (!slugs.has(slug)) {
20+
missing.push({ path: post.path, href, slug })
21+
}
22+
}
23+
}
24+
25+
return missing
26+
}
27+
28+
function run() {
29+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
30+
const postsDir = path.join(root, "content/posts")
31+
const posts = fs.readdirSync(postsDir, { recursive: true })
32+
.filter((file) => file.endsWith(".md"))
33+
.map((file) => {
34+
const filePath = path.join(postsDir, file)
35+
return {
36+
path: path.relative(root, filePath),
37+
content: fs.readFileSync(filePath, "utf8"),
38+
}
39+
})
40+
const missing = findMissingPostLinks(posts)
41+
42+
if (missing.length > 0) {
43+
console.error("Missing internal post links:")
44+
for (const item of missing) {
45+
console.error(`- ${item.path}: ${item.href}`)
46+
}
47+
process.exitCode = 1
48+
return
49+
}
50+
51+
console.log(`Validated ${posts.length} posts with no missing internal post links.`)
52+
}
53+
54+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
55+
run()
56+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import assert from "node:assert/strict"
2+
import test from "node:test"
3+
import { findMissingPostLinks } from "./check-internal-post-links.mjs"
4+
5+
test("존재하지 않는 게시글 slug를 내부 링크 오류로 보고한다", () => {
6+
const missing = findMissingPostLinks([
7+
{
8+
path: "content/posts/existing.md",
9+
content: "[누락 글](/posts/not-published)",
10+
},
11+
{
12+
path: "content/posts/other.md",
13+
content: "[존재 글](/posts/existing)",
14+
},
15+
])
16+
17+
assert.deepEqual(missing, [
18+
{
19+
path: "content/posts/existing.md",
20+
href: "/posts/not-published",
21+
slug: "not-published",
22+
},
23+
])
24+
})

0 commit comments

Comments
 (0)