#!/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)