웹 운영
PHP 단일 엔트리 사이트의 sitemap과 canonical을 스크립트로 한 번에 점검하기
PHP 파일 하나로 만든 사이트에서 sitemap과 canonical이 어긋나지 않게 주소 규칙을 한곳에 두는 방법과, 배포 때마다 돌리는 Python 점검 스크립트 전체를 정리했습니다.
rapsso.com은 PHP 파일 하나(app.php)가 요청 경로를 보고 홈, 글 목록, 글 본문, RSS, sitemap을 전부 만들어 냅니다. 워드프레스를 걷어내고 이렇게 바꾼 뒤로 제일 신경 쓰는 게 화면에 있는 주소와 sitemap에 있는 주소가 어긋나는 것입니다.
이 글은 그걸 코드에서 어떻게 막았고, 배포할 때마다 무엇을 어떤 스크립트로 확인하는지 정리한 겁니다. 스크립트는 그대로 가져다 쓸 수 있게 전체를 붙였습니다.
주소를 만드는 곳을 하나로 둡니다
핵심은 글 목록 배열 하나($posts)를 화면, RSS, sitemap이 전부 같이 읽는 겁니다. sitemap은 이 배열을 돌면서 주소를 만듭니다.
$paths = [
'/' => $latestPostDate,
'/notes/' => $latestPostDate,
];
foreach ($posts as $postSlug => $post) {
$paths['/notes/' . $postSlug . '/'] = !empty($post['published_at'])
? substr((string) $post['published_at'], 0, 10)
: UPDATED_AT;
}
foreach (['/about/', '/contact/', '/privacy/', '/terms/'] as $staticPath) {
$paths[$staticPath] = UPDATED_AT;
}
canonical은 요청 경로에서 끝 슬래시를 정리해 한 줄로 만듭니다. 그래서 슬래시가 없는 주소나 쿼리 문자열이 붙은 주소로 들어와도 canonical은 항상 슬래시가 붙은 한 가지 형태로 나옵니다.
$canonical = SITE_URL . ($path === '/' ? '/' : rtrim($path, '/') . '/');
sitemap에 넣는 주소도 /notes/슬러그/ 형태라 두 쪽이 같은 규칙을 따릅니다. 한쪽만 고치면 어긋나기 때문에, 고정 페이지를 추가할 때도 위의 $staticPath 목록과 라우팅을 같이 고칩니다. 8월에 개인정보처리방침(/privacy/)을 추가할 때 이 목록에 같이 넣었습니다.
글을 내리면 sitemap에서도 자동으로 빠집니다
이 구조의 장점은 오늘 바로 확인할 일이 있었습니다. 사이트 방향을 정리하면서 공개 글 11개 중 8개를 내렸는데, 글 저장소에서 빼는 것만으로 sitemap 주소가 17개에서 9개로 줄었습니다. sitemap을 따로 손댈 필요가 없었습니다.
내린 글 주소는 404 대신 410(Gone)을 돌려주게 했습니다. 404는 "지금 없다"라서 검색엔진이 한동안 다시 와 보지만, 410은 "일부러 지웠다"는 뜻이라 색인에서 조금 더 빨리 빠집니다.
} else {
$page = '404';
$status = in_array($slug, REMOVED_POST_SLUGS, true) ? 410 : 404;
}
배포 뒤에는 스크립트로 한 번에 확인합니다
sitemap이 열린다고 끝이 아닙니다. sitemap에 있는 주소 하나하나가 200을 주는지, 그 페이지의 canonical이 자기 자신을 가리키는지, 제목과 설명이 겹치지 않는지, JSON-LD가 깨지지 않았는지를 봐야 합니다. 이걸 매번 손으로 하기 싫어서 Python 표준 라이브러리만으로 스크립트를 만들었습니다.
import json, re, sys, urllib.request, xml.etree.ElementTree as ET
from collections import Counter
SITEMAP = sys.argv[1]
UA = {"User-Agent": "Mozilla/5.0 (sitemap-audit)"}
def get(url):
req = urllib.request.Request(url, headers=UA)
try:
with urllib.request.urlopen(req, timeout=15) as r:
return r.status, r.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, ""
def one(pattern, html):
m = re.search(pattern, html, re.I | re.S)
return m.group(1).strip() if m else None
_, xml = get(SITEMAP)
ns = {"s": "http://www.sitemaps.org/schemas/sitemap/0.9"}
urls = [loc.text for loc in ET.fromstring(xml).findall("s:url/s:loc", ns)]
rows, titles, descs = [], Counter(), Counter()
for url in urls:
status, html = get(url)
canonical = one(r'<link rel="canonical" href="([^"]+)"', html)
title = one(r"<title>(.*?)</title>", html)
desc = one(r'<meta name="description" content="([^"]*)"', html)
robots = one(r'<meta name="robots" content="([^"]*)"', html) or ""
h1 = len(re.findall(r"<h1\b", html, re.I))
try:
for block in re.findall(r'<script type="application/ld\+json">(.*?)</script>', html, re.S):
json.loads(block)
ld = "ok"
except ValueError:
ld = "BROKEN"
titles[title] += 1
descs[desc] += 1
problems = []
if status != 200: problems.append(f"status {status}")
if canonical != url: problems.append(f"canonical {canonical}")
if not desc: problems.append("no description")
if "noindex" in robots: problems.append("noindex in sitemap")
if h1 != 1: problems.append(f"h1 x{h1}")
if ld != "ok": problems.append("json-ld")
rows.append((url, problems, title, desc))
for url, problems, title, desc in rows:
if titles[title] > 1: problems.append("duplicate title")
if descs[desc] > 1: problems.append("duplicate description")
print(("OK " if not problems else "FAIL ") + url + (" " + ", ".join(problems) if problems else ""))
print(f"{len(urls)} urls, {sum(1 for r in rows if r[1])} with problems")
2026년 9월 27일, 글 8개를 내린 직후 돌린 결과입니다.
$ python audit_sitemap.py https://rapsso.com/sitemap.xml
OK https://rapsso.com/
OK https://rapsso.com/notes/
OK https://rapsso.com/notes/mobile-viewport-minimum-width-check/
OK https://rapsso.com/notes/php-sitemap-canonical-verification-order/
OK https://rapsso.com/notes/email-obfuscation-internal-404-check/
OK https://rapsso.com/about/
OK https://rapsso.com/contact/
OK https://rapsso.com/privacy/
OK https://rapsso.com/terms/
9 urls, 0 with problems
스크립트가 잡는 항목은 이렇습니다. 하나라도 걸리면 그 URL 줄이 FAIL로 바뀌고 이유가 뒤에 붙습니다.
- HTTP 상태가 200이 아님
- canonical이 sitemap의 주소와 다름
- 설명 메타 태그 없음, 또는 다른 페이지와 설명이 똑같음
- 제목이 다른 페이지와 똑같음
- sitemap에 넣어 놓고
noindex를 걸어 둠 (검색엔진에 서로 다른 신호를 주는 흔한 실수) h1이 없거나 두 개 이상- JSON-LD 블록이 JSON으로 안 읽힘
sitemap 밖의 주소도 몇 개 두드려 봤습니다
sitemap에 없는 변형 주소로 들어왔을 때 어떻게 되는지도 확인했습니다. 중복 페이지가 생기는지 보려는 겁니다.
| 요청한 주소 | 응답 | canonical |
|---|---|---|
끝 슬래시 없음 /notes/글/ → /notes/글 | 200 | 슬래시 붙은 주소 |
쿼리 붙음 /notes/글/?utm_source=x | 200 | 쿼리 없는 주소 |
슬래시 두 번 /notes//글/ | 404 | - |
/index.php/notes/ | 404 | - |
http://로 접속 | 301 → https:// | - |
슬래시 없는 주소는 리다이렉트 없이 200을 주지만 canonical이 원래 주소를 가리키고 있어서 중복으로 잡히진 않습니다. 301로 보내는 게 더 깔끔하긴 한데, 지금은 canonical로 충분하다고 보고 그대로 뒀습니다.
정리하면 두 가지입니다. 주소를 만드는 규칙은 한 곳에만 두고, 배포할 때마다 sitemap 전체를 스크립트로 돌립니다. 둘 중 하나만 해도 대부분은 잡히지만, 둘 다 하면 눈으로 확인할 일이 거의 없어집니다.