Files
document-haness/docs/TechLog/final/evidence/terminal/audit/link-audit.py
T

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)