84 lines
3.3 KiB
Python
Executable File
84 lines
3.3 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""서버가 내보내는 모든 공개 주소를 모아 각각에 GET 을 보낸다.
|
|
|
|
주소는 게시 시점에 서버가 만들어 DB(public_resource_projection.navigation_path)에
|
|
저장한 문자열이다. 그래서 저장소 안의 `to=` / `href=` 리터럴만 훑는 감사로는 잡히지
|
|
않는다 — 실제로 결정 링크가 그렇게 숨어 있었다.
|
|
|
|
사용법: python3 link-audit.py [BASE] (기본 https://hyeonworks.com)
|
|
"""
|
|
import json, re, sys, urllib.request
|
|
from collections import defaultdict
|
|
|
|
BASE = sys.argv[1] if len(sys.argv) > 1 else "https://hyeonworks.com"
|
|
|
|
def get(path):
|
|
try:
|
|
with urllib.request.urlopen(BASE + path, timeout=20) as r:
|
|
return json.loads(r.read())
|
|
except Exception as e:
|
|
return {"__error__": str(e)}
|
|
|
|
def status(path):
|
|
# 앵커와 질의 문자열은 라우트를 고르지 않는다 — 떼고 확인한다.
|
|
target = path.split("#")[0].split("?")[0]
|
|
try:
|
|
with urllib.request.urlopen(BASE + target, timeout=20) as r:
|
|
return r.status
|
|
except Exception as e:
|
|
return getattr(e, "code", "ERR")
|
|
|
|
paths = defaultdict(set) # path -> 어디서 나왔나
|
|
|
|
def collect(node, origin):
|
|
if isinstance(node, dict):
|
|
for key in ("path", "canonicalPath", "projectPath"):
|
|
value = node.get(key)
|
|
if isinstance(value, str) and value.startswith("/"):
|
|
paths[value].add(origin)
|
|
for value in node.values():
|
|
collect(value, origin)
|
|
elif isinstance(node, list):
|
|
for value in node:
|
|
collect(value, origin)
|
|
|
|
# 1) 목록에서 시작한다
|
|
for seed in ("/api/v1/public/home", "/api/v1/public/topics", "/api/v1/public/projects",
|
|
"/api/v1/public/knowledge", "/api/v1/public/questions", "/api/v1/public/releases"):
|
|
collect(get(seed), seed)
|
|
|
|
# 2) 문서 상세를 전부 돈다 — 관계는 상세에만 있다
|
|
SEGMENTS = ("cases", "references", "questions", "concepts")
|
|
for path in [p for p in list(paths) if re.match(rf"^/({'|'.join(SEGMENTS)})/[^/]+$", p)]:
|
|
segment, slug = path.strip("/").split("/", 1)
|
|
collect(get(f"/api/v1/public/{segment}/{slug}"), path)
|
|
|
|
# 3) 프로젝트의 하위 목록
|
|
for path in [p for p in list(paths) if re.match(r"^/projects/[^/]+$", p)]:
|
|
slug = path.rsplit("/", 1)[-1]
|
|
for sub in ("", "/decisions", "/records", "/activity"):
|
|
collect(get(f"/api/v1/public/projects/{slug}{sub}"), path + sub)
|
|
|
|
# 4) 주제 허브와 축 — 목록 응답에 path 가 없어 위 크롤이 닿지 않는다
|
|
for topic in (get("/api/v1/public/topics").get("data") or {}).get("items", []):
|
|
paths[f"/topics/{topic['slug']}"].add("listPublicTopics")
|
|
detail = (get(f"/api/v1/public/topics/{topic['slug']}").get("data") or {})
|
|
for variant in (detail.get("variants") or []):
|
|
if variant.get("path"):
|
|
paths[variant["path"]].add(f"/topics/{topic['slug']}")
|
|
|
|
bad = []
|
|
for path in sorted(paths):
|
|
code = status(path)
|
|
print(f" {code} {path}")
|
|
if code != 200:
|
|
bad.append((code, path, sorted(paths[path])[:2]))
|
|
|
|
print(f"\n검사한 주소 {len(paths)}개")
|
|
if not bad:
|
|
print("죽은 링크 없음")
|
|
else:
|
|
for code, path, origin in bad:
|
|
print(f" DEAD {code} {path} ← {origin}")
|
|
sys.exit(1 if bad else 0)
|