First Commit
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
detections / detections (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
detections / detections (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
This commit is contained in:
Executable
+204
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env python3
|
||||
"""저장소 안 마크다운 문서의 내부 링크·이미지·앵커 참조 무결성을 검사한다.
|
||||
|
||||
추적되는 모든 `.md` 문서를 훑어 두 가지를 확인한다.
|
||||
|
||||
1. 저장소 안 다른 파일을 가리키는 상대 경로 링크(`[text](path)`)와 이미지
|
||||
참조(``·`<img src="path">`)가 실제로 존재하는 파일을 가리키는지.
|
||||
2. 문서 앵커 링크(`[text](#heading)`·`[text](other.md#heading)`)가 대상 `.md`
|
||||
문서에 실제로 있는 헤딩을 가리키는지. 앵커 slug 는 GitHub 의 규칙
|
||||
(소문자화 → `\\p{Word}`·하이픈·공백만 남기고 제거 → 공백을 하이픈으로,
|
||||
같은 slug 가 겹치면 등장 순서대로 `-1`·`-2` 접미)을 표준 라이브러리로
|
||||
재현해 생성한다.
|
||||
|
||||
깨진 참조가 하나라도 있으면 종료 코드 1 로 끝나므로, CI 머지 게이트
|
||||
(`.github/workflows/docs.yml`)와 기여자의 로컬 검증에 그대로 쓸 수 있다.
|
||||
|
||||
검사 대상이 아닌 것:
|
||||
- 외부 URL(`http://`·`https://`·`mailto:`·`tel:`·`data:`): 네트워크에 의존해
|
||||
flaky 하므로 이 결정론적 게이트에서는 다루지 않는다. 외부 링크 상태는 별도로
|
||||
점검한다.
|
||||
- `.md` 가 아닌 파일을 가리키는 링크의 `#` 뒤 조각(예: 소스 코드 줄 번호): 헤딩
|
||||
앵커가 아니므로 파일 실존만 확인하고 앵커는 검사하지 않는다.
|
||||
- 이미지 참조의 `#` 조각: 이미지에는 헤딩 앵커가 의미가 없다.
|
||||
- fenced code block(``` 또는 ~~~ 로 감싼 블록) 안의 예시 링크·헤딩 꼴 주석:
|
||||
실제 참조·헤딩이 아니라 코드 예시이므로 건너뛴다.
|
||||
|
||||
표준 라이브러리만 쓰고 네트워크에 접속하지 않는다. 실행: `python3 -I scripts/check-doc-links.py`
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import unicodedata
|
||||
from collections import defaultdict
|
||||
|
||||
# [text](path) 중 이미지(`![...]`)가 아닌 링크. 경로는 공백 전까지(제목 "..." 제외).
|
||||
MD_LINK = re.compile(r"(?<!\!)\[[^\]]*\]\(([^)\s]+)")
|
||||
#  이미지 링크.
|
||||
MD_IMAGE = re.compile(r"!\[[^\]]*\]\(([^)\s]+)")
|
||||
# <img ... src="path" ...> HTML 이미지.
|
||||
HTML_IMAGE = re.compile(r"<img[^>]*\bsrc=[\"']([^\"']+)[\"']", re.IGNORECASE)
|
||||
|
||||
# 네트워크·비파일 스킴. 앵커(`#`)는 같은 문서 참조이므로 여기 넣지 않는다.
|
||||
EXTERNAL_PREFIXES = ("http://", "https://", "mailto:", "tel:", "data:")
|
||||
|
||||
ATX_HEADING = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$")
|
||||
# 헤딩 텍스트 안의 인라인 마크다운을 걷어내 순수 텍스트만 남긴다.
|
||||
MD_INLINE_LINK = re.compile(r"\[([^\]]*)\]\([^)]*\)")
|
||||
|
||||
|
||||
def is_external(target: str) -> bool:
|
||||
return target.startswith(EXTERNAL_PREFIXES)
|
||||
|
||||
|
||||
def _is_word_char(ch: str) -> bool:
|
||||
"""GitHub 의 앵커 slug 규칙이 남기는 `\\p{Word}` 문자인지 판정한다.
|
||||
|
||||
Ruby 정규식 `\\p{Word}` = Letter(L*) + Mark(M*) + Decimal_Number(Nd)
|
||||
+ Connector_Punctuation(Pc) + Join_Control(U+200C·U+200D). 한글·한자·
|
||||
언더스코어·이모지의 variation selector(Mn) 등이 그대로 유지된다.
|
||||
"""
|
||||
if ch == "_":
|
||||
return True
|
||||
if ch in ("", ""):
|
||||
return True
|
||||
category = unicodedata.category(ch)
|
||||
if category[0] in ("L", "M"):
|
||||
return True
|
||||
return category in ("Nd", "Pc")
|
||||
|
||||
|
||||
def slugify(text: str) -> str:
|
||||
"""헤딩 텍스트를 GitHub 의 앵커 slug 로 변환한다(중복 접미 처리 제외)."""
|
||||
text = text.strip().lower()
|
||||
out = []
|
||||
for ch in text:
|
||||
if ch == " ":
|
||||
out.append("-")
|
||||
elif ch == "-" or _is_word_char(ch):
|
||||
out.append(ch)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def heading_slugs(root: str, md: str) -> set:
|
||||
"""파일의 모든 ATX 헤딩에서 GitHub 앵커 slug 집합을 만든다.
|
||||
|
||||
같은 slug 가 겹치면 GitHub 처럼 등장 순서대로 `-1`·`-2` 접미를 붙인다.
|
||||
"""
|
||||
slugs = set()
|
||||
counts = defaultdict(int)
|
||||
in_fence = False
|
||||
with open(os.path.join(root, md), encoding="utf-8", errors="ignore") as fh:
|
||||
for line in fh:
|
||||
stripped = line.lstrip()
|
||||
if stripped.startswith("```") or stripped.startswith("~~~"):
|
||||
in_fence = not in_fence
|
||||
continue
|
||||
if in_fence:
|
||||
continue
|
||||
match = ATX_HEADING.match(line.rstrip("\n"))
|
||||
if not match:
|
||||
continue
|
||||
raw = match.group(2)
|
||||
raw = MD_INLINE_LINK.sub(r"\1", raw)
|
||||
raw = raw.replace("`", "").replace("*", "").replace("_", "")
|
||||
base = slugify(raw)
|
||||
seen = counts[base]
|
||||
counts[base] += 1
|
||||
slugs.add(base if seen == 0 else f"{base}-{seen}")
|
||||
return slugs
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = subprocess.check_output(
|
||||
["git", "rev-parse", "--show-toplevel"], text=True
|
||||
).strip()
|
||||
tracked = subprocess.check_output(
|
||||
["git", "ls-files"], cwd=root, text=True
|
||||
).splitlines()
|
||||
md_files = [f for f in tracked if f.endswith(".md")]
|
||||
|
||||
slug_cache: dict = {}
|
||||
|
||||
def slugs_of(md_path: str) -> set:
|
||||
if md_path not in slug_cache:
|
||||
slug_cache[md_path] = heading_slugs(root, md_path)
|
||||
return slug_cache[md_path]
|
||||
|
||||
checked_files = 0
|
||||
checked_anchors = 0
|
||||
broken_files = []
|
||||
broken_anchors = []
|
||||
for md in md_files:
|
||||
with open(os.path.join(root, md), encoding="utf-8", errors="ignore") as fh:
|
||||
lines = fh.readlines()
|
||||
base_dir = os.path.dirname(md)
|
||||
in_fence = False
|
||||
for lineno, line in enumerate(lines, 1):
|
||||
stripped = line.lstrip()
|
||||
if stripped.startswith("```") or stripped.startswith("~~~"):
|
||||
in_fence = not in_fence
|
||||
continue
|
||||
if in_fence:
|
||||
continue
|
||||
for pattern in (MD_LINK, MD_IMAGE, HTML_IMAGE):
|
||||
for match in pattern.finditer(line):
|
||||
target = match.group(1).strip()
|
||||
if is_external(target):
|
||||
continue
|
||||
|
||||
if target.startswith("#"):
|
||||
file_part, anchor = "", target[1:]
|
||||
elif "#" in target:
|
||||
file_part, anchor = target.split("#", 1)
|
||||
else:
|
||||
file_part, anchor = target, ""
|
||||
file_part = file_part.split("?", 1)[0]
|
||||
anchor = anchor.strip()
|
||||
|
||||
# 가리키는 .md 문서(앵커 검사 대상). 다른 파일 링크면 그 파일,
|
||||
# 파일 조각이 없는 `#앵커`면 자기 문서.
|
||||
target_md = None
|
||||
if file_part:
|
||||
checked_files += 1
|
||||
resolved = os.path.normpath(
|
||||
os.path.join(root, base_dir, file_part)
|
||||
)
|
||||
if not os.path.exists(resolved):
|
||||
broken_files.append((md, lineno, target))
|
||||
continue
|
||||
if file_part.endswith(".md"):
|
||||
target_md = os.path.normpath(
|
||||
os.path.join(base_dir, file_part)
|
||||
)
|
||||
else:
|
||||
target_md = md
|
||||
|
||||
# 앵커는 본문 링크(MD_LINK)에만 의미가 있다. 이미지 참조의
|
||||
# `#` 조각은 건너뛴다.
|
||||
if anchor and target_md is not None and pattern is MD_LINK:
|
||||
checked_anchors += 1
|
||||
if anchor not in slugs_of(target_md):
|
||||
broken_anchors.append((md, lineno, target, target_md))
|
||||
|
||||
print(
|
||||
f"추적 마크다운 {len(md_files)}개 · 파일 참조 {checked_files}개 · "
|
||||
f"앵커 참조 {checked_anchors}개 검사"
|
||||
)
|
||||
if broken_files:
|
||||
print(f"깨진 파일 참조 {len(broken_files)}개 — 가리키는 파일이 저장소에 없습니다:")
|
||||
for md, lineno, target in broken_files:
|
||||
print(f" {md}:{lineno} -> {target}")
|
||||
if broken_anchors:
|
||||
print(f"깨진 앵커 참조 {len(broken_anchors)}개 — 대상 문서에 그 헤딩이 없습니다:")
|
||||
for md, lineno, target, target_md in broken_anchors:
|
||||
print(f" {md}:{lineno} -> {target} (대상: {target_md})")
|
||||
if broken_files or broken_anchors:
|
||||
return 1
|
||||
print("깨진 참조 0 — 모든 내부 링크·이미지·앵커가 실존 대상을 가리킵니다.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+277
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env python3
|
||||
"""추적되는 마크다운 문서가 가리키는 외부 링크(http·https)가 아직 살아 있는지 점검한다.
|
||||
|
||||
자매 스크립트 `check-doc-links.py` 는 저장소 안 내부 링크·앵커만 보고 외부 URL 은
|
||||
설계상 건너뛴다(네트워크에 의존해 flaky 하므로 머지 게이트에서 다루지 않는다). 이
|
||||
스크립트가 그 "별도 점검"을 맡는다. README 최상단·방어 가이드·detections README 가
|
||||
방문자와 방어자에게 "여기로 가 보라"고 안내하는 외부 링크(사고 신고 창구 boho.or.kr·
|
||||
privacy.go.kr·pipc.go.kr·fsec.or.kr, CISA KEV, OWASP·SigmaHQ·Suricata·MITRE·MISP,
|
||||
원본 데모 artex-demo.vercel.app, GitHub 배지 등)가 변질·이동·폐쇄되면 조용히 깨진
|
||||
채로 남는데, 그것을 주기적으로·수동으로 잡아낸다.
|
||||
|
||||
점검 대상 URL 을 고르는 규칙
|
||||
- 추적되는 모든 `.md` 를 훑되, fenced code block(``` 또는 ~~~)과 인라인 코드 스팬
|
||||
(`` `...` ``) 안의 URL 은 건너뛴다. 그 안의 URL 은 명령 예시·설정 값·인용된 외부
|
||||
자산(예: 곁질문 검증 문서의 `id.redhaze.top`)이라 "독자가 따라갈 참조 링크"가 아니다.
|
||||
- 마크다운 링크·이미지(`[text](url)`·``), 자동 링크(`<url>`), 그리고 남은
|
||||
산문 안의 맨 URL 에서 http·https 주소를 모은다.
|
||||
- 예약·플레이스홀더 호스트는 제외한다: localhost·사설/루프백 IP(127.·10.·192.168.·
|
||||
169.254.·172.16~31.·0.0.0.0·::1), RFC 2606/6761 예약(example.com/org/net/edu·
|
||||
`*.example.*`·`.test`·`.invalid`·`.local`·`.tld`), 점이 없어 FQDN 이 아닌 이름(예: `target`).
|
||||
|
||||
살아 있는지 확인하는 방법 (MAINTAINING.md 8.2 의 교훈을 코드로 옮긴 것)
|
||||
국내 공공·보안 기관 사이트는 HEAD 요청·기본 User-Agent 를 거부하거나 여러 번
|
||||
리다이렉트하므로, 단순 확인은 멀쩡한 링크를 깨진 것으로 오인한다. 그래서 이 스크립트는
|
||||
**브라우저 User-Agent 로, GET 으로, 리다이렉트를 따라가며** 확인한다. 상태를 세 가지로
|
||||
나눈다.
|
||||
- OK: 최종 상태가 2xx·3xx. 링크가 유효하다.
|
||||
- RESTRICTED: 401·403·405·429. 호스트는 살아 있으나 확인 방법이 서버 접근 정책(봇 차단·
|
||||
메서드 거부·속도 제한)에 막힌 것일 뿐 깨진 링크가 아니다. 보고하되 실패로 치지 않는다.
|
||||
- DOWN: 404·410·5xx(재시도 후에도)·DNS/연결/타임아웃/SSL 오류(재시도 후에도). 실제로
|
||||
깨졌을 가능성이 높다.
|
||||
|
||||
네트워크 오류·5xx·429 는 소폭 지연을 두고 재시도해 일시적 깜빡임과 진짜 장애를 가른다.
|
||||
표준 라이브러리만 쓴다. 이 스크립트는 저장소의 **공개 문서가 이미 가리키는** 참조 URL 에만
|
||||
GET 을 보내 생존을 확인할 뿐, 어떤 대상도 스캔·탐침하지 않는다.
|
||||
|
||||
알려진 예외(allowlist)
|
||||
`scripts/external-links-allowlist.txt` 에 적힌 URL 이 DOWN 으로 나오면 "ALLOWED" 로 따로
|
||||
분류하고 strict 종료 코드에 넣지 않는다. 우리가 소유하지 않아 고칠 수 없는, 상류 원문 보존
|
||||
파일(예: 상류 CHANGELOG.zh.md)이 물려받은 죽은 링크를 투명하게 기록해, 주기 strict 점검이
|
||||
그 하나 때문에 영구히 빨갛게 되지 않고 **새로 깨진 링크가 생길 때만** 빨갛게 되도록 한다.
|
||||
|
||||
실행
|
||||
- 기본(보고·종료 코드 0): `python3 -I scripts/check-external-links.py`
|
||||
- 머지 게이트가 아닌 주기/릴리스 점검(새로 깨진 링크가 있으면 빨갛게): `--strict` (allowlist 에
|
||||
없는 DOWN 이 하나라도 있으면 종료 코드 1). RESTRICTED·ALLOWED 는 strict 에서도 실패로 치지 않는다.
|
||||
- 네트워크 없이 추출 집합만 미리 보기: `--list` (호출 없이 점검 대상 URL 과 출처를 출력).
|
||||
"""
|
||||
import argparse
|
||||
import http.client
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections import defaultdict
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
# [text](url) 본문 링크(이미지 아님)와  이미지. 경로는 공백·닫는 괄호 전까지.
|
||||
MD_LINK = re.compile(r"(?<!\!)\[[^\]]*\]\(([^)\s]+)")
|
||||
MD_IMAGE = re.compile(r"!\[[^\]]*\]\(([^)\s]+)")
|
||||
# <https://...> 자동 링크.
|
||||
AUTOLINK = re.compile(r"<(https?://[^>\s]+)>")
|
||||
# 산문 안의 맨 URL. 뒤따르는 구두점은 뒤에서 벗겨낸다.
|
||||
BARE_URL = re.compile(r"https?://[^\s)>\]\"'`]+")
|
||||
# 인라인 코드 스팬 `...` (단일 백틱). 추출 전에 공백으로 지운다.
|
||||
INLINE_CODE = re.compile(r"`[^`]*`")
|
||||
# 산문 URL 끝에 흔히 붙는 구두점.
|
||||
TRAILING_PUNCT = ".,;:!?\"'»)]}>"
|
||||
|
||||
# IPv4 사설/루프백/링크로컬 대역(172.16~31. 은 별도 패턴).
|
||||
PRIVATE_IPV4 = re.compile(r"^(127\.|10\.|192\.168\.|169\.254\.|0\.0\.0\.0$)")
|
||||
PRIVATE_IPV4_172 = re.compile(r"^172\.(1[6-9]|2\d|3[01])\.")
|
||||
|
||||
BROWSER_UA = (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0 Safari/537.36"
|
||||
)
|
||||
|
||||
# 호스트는 살아 있으나 확인 방법이 서버 정책에 막힌 상태 코드(깨짐 아님).
|
||||
RESTRICTED_CODES = {401, 403, 405, 429}
|
||||
|
||||
ALLOWLIST_PATH = os.path.join("scripts", "external-links-allowlist.txt")
|
||||
|
||||
|
||||
def load_allowlist(root: str) -> set:
|
||||
"""우리가 고칠 수 없는 알려진 DOWN URL 집합을 읽는다(없으면 빈 집합).
|
||||
|
||||
한 줄에 URL 하나. `#` 뒤는 주석, 빈 줄·주석 전용 줄은 무시한다.
|
||||
"""
|
||||
path = os.path.join(root, ALLOWLIST_PATH)
|
||||
allow = set()
|
||||
if not os.path.exists(path):
|
||||
return allow
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.split("#", 1)[0].strip()
|
||||
if line:
|
||||
allow.add(line)
|
||||
return allow
|
||||
|
||||
|
||||
def strip_code(line: str) -> str:
|
||||
"""fence 밖 한 줄에서 인라인 코드 스팬을 지운다(그 안의 URL 은 참조가 아니다)."""
|
||||
return INLINE_CODE.sub(" ", line)
|
||||
|
||||
|
||||
def is_checkable(url: str) -> bool:
|
||||
"""예약·플레이스홀더 호스트를 걸러, 실제 점검할 가치가 있는 외부 URL 만 남긴다."""
|
||||
parts = urlsplit(url)
|
||||
if parts.scheme not in ("http", "https"):
|
||||
return False
|
||||
host = parts.hostname
|
||||
if not host:
|
||||
return False
|
||||
host = host.lower()
|
||||
if host == "localhost" or host.endswith(".localhost"):
|
||||
return False
|
||||
if host == "::1":
|
||||
return False
|
||||
if PRIVATE_IPV4.match(host) or PRIVATE_IPV4_172.match(host):
|
||||
return False
|
||||
if host in ("example.com", "example.org", "example.net", "example.edu"):
|
||||
return False
|
||||
if host.endswith((".example.com", ".example.org", ".example.net", ".example")):
|
||||
return False
|
||||
if host.endswith((".test", ".invalid", ".local", ".localdomain", ".tld")):
|
||||
return False
|
||||
if "." not in host: # FQDN 이 아닌 맨 이름(예: target)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def extract(root: str, md_files: list) -> dict:
|
||||
"""추적 .md 에서 점검할 외부 URL → [(파일, 줄번호), ...] 사전을 만든다."""
|
||||
found = defaultdict(list)
|
||||
for md in md_files:
|
||||
with open(os.path.join(root, md), encoding="utf-8", errors="ignore") as fh:
|
||||
lines = fh.readlines()
|
||||
in_fence = False
|
||||
for lineno, raw in enumerate(lines, 1):
|
||||
stripped = raw.lstrip()
|
||||
if stripped.startswith("```") or stripped.startswith("~~~"):
|
||||
in_fence = not in_fence
|
||||
continue
|
||||
if in_fence:
|
||||
continue
|
||||
line = strip_code(raw)
|
||||
candidates = []
|
||||
for pattern in (MD_LINK, MD_IMAGE, AUTOLINK):
|
||||
candidates.extend(m.group(1) for m in pattern.finditer(line))
|
||||
for m in BARE_URL.finditer(line):
|
||||
candidates.append(m.group(0).rstrip(TRAILING_PUNCT))
|
||||
for url in candidates:
|
||||
url = url.strip().rstrip(TRAILING_PUNCT)
|
||||
if is_checkable(url):
|
||||
where = (md, lineno)
|
||||
if where not in found[url]:
|
||||
found[url].append(where)
|
||||
return found
|
||||
|
||||
|
||||
def probe(url: str, timeout: float, retries: int, delay: float):
|
||||
"""URL 에 브라우저 UA 로 GET 을 보내 (분류, 상세) 를 돌려준다.
|
||||
|
||||
분류는 "OK" · "RESTRICTED" · "DOWN". 네트워크 오류·5xx·429 는 재시도한다.
|
||||
"""
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
method="GET",
|
||||
headers={
|
||||
"User-Agent": BROWSER_UA,
|
||||
"Accept": "*/*",
|
||||
"Accept-Language": "ko,en;q=0.8",
|
||||
},
|
||||
)
|
||||
last = ""
|
||||
for attempt in range(retries + 1):
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
code = resp.getcode()
|
||||
if code in RESTRICTED_CODES:
|
||||
return "RESTRICTED", f"HTTP {code}"
|
||||
return "OK", f"HTTP {code}"
|
||||
except urllib.error.HTTPError as exc:
|
||||
code = exc.code
|
||||
if code in RESTRICTED_CODES:
|
||||
return "RESTRICTED", f"HTTP {code}"
|
||||
if code >= 500 or code == 408:
|
||||
last = f"HTTP {code}" # 일시적일 수 있어 재시도
|
||||
else:
|
||||
return "DOWN", f"HTTP {code}" # 404·410 등은 확정, 재시도 불요
|
||||
except (
|
||||
urllib.error.URLError,
|
||||
http.client.HTTPException,
|
||||
ssl.SSLError,
|
||||
socket.timeout,
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
OSError,
|
||||
) as exc:
|
||||
reason = getattr(exc, "reason", exc)
|
||||
last = f"{type(exc).__name__}: {reason}"
|
||||
if attempt < retries:
|
||||
time.sleep(delay * (attempt + 1))
|
||||
return "DOWN", last
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="추적 마크다운의 외부 링크 생존 점검")
|
||||
ap.add_argument("--strict", action="store_true",
|
||||
help="DOWN 이 하나라도 있으면 종료 코드 1 (주기/릴리스 점검용)")
|
||||
ap.add_argument("--list", action="store_true",
|
||||
help="네트워크 호출 없이 점검 대상 URL 과 출처만 출력")
|
||||
ap.add_argument("--timeout", type=float, default=15.0, help="요청 타임아웃(초)")
|
||||
ap.add_argument("--retries", type=int, default=2, help="네트워크 오류·5xx·429 재시도 횟수")
|
||||
ap.add_argument("--delay", type=float, default=0.5, help="호출 간·재시도 간 기본 지연(초)")
|
||||
args = ap.parse_args()
|
||||
|
||||
root = subprocess.check_output(
|
||||
["git", "rev-parse", "--show-toplevel"], text=True
|
||||
).strip()
|
||||
tracked = subprocess.check_output(["git", "ls-files"], cwd=root, text=True).splitlines()
|
||||
md_files = [f for f in tracked if f.endswith(".md")]
|
||||
|
||||
allow = load_allowlist(root)
|
||||
found = extract(root, md_files)
|
||||
urls = sorted(found)
|
||||
print(f"추적 마크다운 {len(md_files)}개에서 점검 대상 외부 URL {len(urls)}개 수집")
|
||||
|
||||
if args.list:
|
||||
for url in urls:
|
||||
where = ", ".join(f"{f}:{ln}" for f, ln in found[url])
|
||||
tag = " [allowlist]" if url in allow else ""
|
||||
print(f" {url}{tag} ({where})")
|
||||
return 0
|
||||
|
||||
results = {"OK": [], "RESTRICTED": [], "ALLOWED": [], "DOWN": []}
|
||||
for i, url in enumerate(urls):
|
||||
if i:
|
||||
time.sleep(args.delay) # 호출 간 소폭 지연(샌드박스 조절 회피)
|
||||
verdict, detail = probe(url, args.timeout, args.retries, args.delay)
|
||||
if verdict == "DOWN" and url in allow:
|
||||
verdict = "ALLOWED" # 고칠 수 없는 알려진 상류 상속 DOWN
|
||||
results[verdict].append((url, detail))
|
||||
print(f" [{verdict:10}] {url} — {detail}")
|
||||
|
||||
print(
|
||||
f"\n요약: OK {len(results['OK'])} · RESTRICTED {len(results['RESTRICTED'])} · "
|
||||
f"ALLOWED {len(results['ALLOWED'])} · DOWN {len(results['DOWN'])}"
|
||||
)
|
||||
if results["RESTRICTED"]:
|
||||
print("RESTRICTED(호스트 생존·확인 방법이 막힘, 깨진 링크 아님):")
|
||||
for url, detail in results["RESTRICTED"]:
|
||||
print(f" {url} — {detail}")
|
||||
if results["ALLOWED"]:
|
||||
print("ALLOWED(allowlist 에 적힌 알려진 DOWN — 우리가 고칠 수 없어 strict 제외):")
|
||||
for url, detail in results["ALLOWED"]:
|
||||
where = ", ".join(f"{f}:{ln}" for f, ln in found[url])
|
||||
print(f" {url} — {detail} (참조: {where})")
|
||||
if results["DOWN"]:
|
||||
print("DOWN(깨졌을 가능성 높음 — 확인 필요):")
|
||||
for url, detail in results["DOWN"]:
|
||||
where = ", ".join(f"{f}:{ln}" for f, ln in found[url])
|
||||
print(f" {url} — {detail} (참조: {where})")
|
||||
|
||||
if args.strict and results["DOWN"]:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+129
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python3
|
||||
"""정적 내보내기한 웹 UI(`web/out`)의 HTML 에 중국어(한자)가 새어 나왔는지 검사한다.
|
||||
|
||||
이 포크의 핵심 성과이자 차별점은 "사용자에게 보이는 화면에 중국어가 없다"는 것이다
|
||||
(백로그 B4 가 수립한 `out` HTML 한자 0 속성). 그 속성을 손검증에만 맡기면, 기여자가
|
||||
중국어 문자열을 하드코딩하거나 번역이 누락되는 회귀가 생겨도 다음 사람이 눈으로
|
||||
발견할 때까지 조용히 깨진 채 남는다. 이 스크립트는 그 회귀를 머지 게이트에서 막는다.
|
||||
|
||||
검사 방식
|
||||
---------
|
||||
`web/out` 아래 모든 `.html` 파일의 **원시 내용**(렌더된 마크업뿐 아니라 인라인
|
||||
스크립트·React Server Components 플라이트 데이터까지)을 훑어 한자가 하나라도 있으면
|
||||
종료 코드 1 로 끝난다. 원시 내용을 그대로 보는 이유는, 사용자에게 보이는 텍스트가
|
||||
플라이트 데이터(JSON 꼴 문자열)에 실려 HTML 에 함께 내려오기 때문이다. 태그만
|
||||
벗겨 내면 그 경로의 누출을 놓친다.
|
||||
|
||||
검사 범위 밖 (정직한 한계)
|
||||
-----------------------
|
||||
이 게이트는 정적 내보내기 결과물만 본다. 그래서 **MOCK 모드(`NEXT_PUBLIC_MOCK=1`,
|
||||
공개 데모가 쓰는 모드)에서 브라우저가 실행 중에 그려 내는 내용**까지는 검사하지 못한다.
|
||||
`web/src/lib/mock/data.ts`·`handler.ts` 와 각 페이지의 하드코딩 샘플은 하이드레이션
|
||||
뒤 `useEffect` 가 `mockHandle()` 로 가져와 React 상태로 렌더하므로, `out` HTML 이
|
||||
아니라 런타임 JS 번들에만 실린다. 즉 이 경로로 들어온 중국어는 이 게이트를 통과한다
|
||||
(과거 공개 데모의 `/system/logs` 샘플 로그에 중국어가 그렇게 조용히 남아 있었고,
|
||||
백로그 G114 에서 한국어로 고쳤다).
|
||||
|
||||
그 표면은 소스 검토로 지킨다. 렌더 대상 파일(`web/src` 의 `.tsx`·클라이언트 데이터)에
|
||||
남는 중국어는 반드시 다음 중 하나여야 한다: 코드 주석, `t.has(...)` 로 막혀 실제로는
|
||||
쓰이지 않는 i18n 폴백, 백엔드 와이어 포맷 토큰(챗 멘션 분류 라벨·`[模型]` 결정 출처
|
||||
센티넬), locale 가 `zh` 일 때만 보이는 분기 리터럴, 또는 성능 보존을 위해 번역하지
|
||||
않는 에이전트 두뇌(段 [A]) 프롬프트를 그대로 비추는 미러. 이 가운데 어디에도 속하지
|
||||
않고 "그냥 화면에 보이는 중국어"라면 한국어로 번역한다(백로그 G115 가 이 분류를
|
||||
전수 확인했다).
|
||||
|
||||
허용·탐지 범위
|
||||
-------------
|
||||
- **한글(`가-힣` 등)은 허용**한다. 한자 블록과 한글 블록은 유니코드에서 겹치지 않으므로,
|
||||
한자 블록만 탐지하면 한글은 자동으로 통과한다.
|
||||
- **한자(CJK 한자)만 탐지**한다: 통합 한자와 확장 A(`㐀`–`鿿`), 호환 한자
|
||||
(`豈`–``), 그리고 보충 평면의 확장 B 이상(`𠀀`–). 가나·전각 문장부호는
|
||||
"한자"가 아니므로 이 게이트의 대상이 아니다(원문 보존 코드 블록이나 증거 인용에
|
||||
섞여 들어와 거짓 양성을 내는 것을 막기 위해 범위를 한자로 좁힌다).
|
||||
|
||||
상류 보존이 필요한 의도적 한자
|
||||
-----------------------------
|
||||
현재 `out` HTML 에는 한자가 한 글자도 없어 허용 목록이 비어 있다. 앞으로 성능 보존을
|
||||
위해 원문(중국어)을 일부러 화면에 남겨야 하는 경우(예: 언어 스위처의 `中文` 라벨)가
|
||||
생기면, 그 글자를 아래 `ALLOWED_HAN` 에 추가하고 왜 남기는지 주석으로 적는다. 그래야
|
||||
게이트는 엄격하게 유지되면서도 의도된 예외만 좁게 통과시킬 수 있다.
|
||||
|
||||
표준 라이브러리만 쓰고 네트워크에 접속하지 않는다.
|
||||
실행: `python3 -I scripts/check-web-cjk.py [out 디렉터리]`
|
||||
(기본값은 저장소 루트의 `web/out`. CI 는 `web` 작업 디렉터리에서
|
||||
`python3 -I ../scripts/check-web-cjk.py` 로 부른다.)
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# CJK 한자 블록만. 한글(AC00–D7A3)·가나·전각 문장부호는 들어 있지 않다.
|
||||
# 범위: 확장 A + 통합 한자(U+3400–U+9FFF = 㐀–鿿), 호환 한자(U+F900–U+FAFF = 豈–),
|
||||
# 보충 평면 확장 B 이상(U+20000–U+2FFFF).
|
||||
HAN = re.compile(r"[㐀-鿿豈-\U00020000-\U0002ffff]")
|
||||
|
||||
# 화면에 일부러 남기는 한자(예: 언어 스위처 `中文`). 지금은 비어 있다 —
|
||||
# 비우면 어떤 한자든 게이트에 걸린다. 예외를 더할 때는 글자와 사유를 함께 적는다.
|
||||
# 예) ALLOWED_HAN = {"中", "文"} # 언어 스위처 '中文' 라벨(상류 대조용)
|
||||
ALLOWED_HAN: set = set()
|
||||
|
||||
|
||||
def find_out_dir() -> str:
|
||||
"""검사할 `out` 디렉터리를 정한다.
|
||||
|
||||
인자로 경로를 주면 그것을, 없으면 git 저장소 루트의 `web/out` 를 쓴다.
|
||||
"""
|
||||
if len(sys.argv) > 1:
|
||||
return os.path.abspath(sys.argv[1])
|
||||
root = subprocess.check_output(
|
||||
["git", "rev-parse", "--show-toplevel"], text=True
|
||||
).strip()
|
||||
return os.path.join(root, "web", "out")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
out_dir = find_out_dir()
|
||||
if not os.path.isdir(out_dir):
|
||||
print(
|
||||
f"오류: 검사할 디렉터리가 없습니다 — {out_dir}\n"
|
||||
"먼저 `npm run build:static` 로 정적 내보내기를 만든 뒤 실행하세요."
|
||||
)
|
||||
return 2
|
||||
|
||||
checked = 0
|
||||
offenders = [] # (상대경로, 한자 개수, 미리보기)
|
||||
for dirpath, _dirnames, filenames in os.walk(out_dir):
|
||||
for name in filenames:
|
||||
if not name.endswith(".html"):
|
||||
continue
|
||||
checked += 1
|
||||
path = os.path.join(dirpath, name)
|
||||
with open(path, encoding="utf-8", errors="ignore") as fh:
|
||||
text = fh.read()
|
||||
found = [ch for ch in HAN.findall(text) if ch not in ALLOWED_HAN]
|
||||
if found:
|
||||
rel = os.path.relpath(path, out_dir)
|
||||
kinds = "".join(sorted(set(found)))
|
||||
offenders.append((rel, len(found), kinds))
|
||||
|
||||
print(f"검사한 HTML {checked}개 (대상: {out_dir})")
|
||||
if offenders:
|
||||
print(f"중국어(한자) 누출 {len(offenders)}개 파일 — 사용자 노출 HTML 에 한자가 있습니다:")
|
||||
for rel, count, kinds in offenders:
|
||||
preview = kinds if len(kinds) <= 30 else kinds[:30] + "…"
|
||||
print(f" out/{rel}: 한자 {count}개 · 종류 {preview}")
|
||||
print(
|
||||
"\n한글로 번역하거나, 상류 보존이 꼭 필요한 의도적 한자라면 "
|
||||
"scripts/check-web-cjk.py 의 ALLOWED_HAN 에 사유와 함께 추가하세요."
|
||||
)
|
||||
return 1
|
||||
if checked == 0:
|
||||
print("경고: 검사한 HTML 이 없습니다 — 빌드가 제대로 되었는지 확인하세요.")
|
||||
return 2
|
||||
print("중국어(한자) 누출 0 — 사용자 노출 HTML 이 전부 한국어/비한자입니다.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,13 @@
|
||||
# check-external-links.py 가 읽는 "알려진 DOWN" allowlist.
|
||||
#
|
||||
# 여기 적힌 URL 이 생존 점검에서 DOWN 으로 나오면 ALLOWED 로 분류하고 --strict 종료
|
||||
# 코드에 넣지 않는다. 우리가 소유하지 않아 고칠 수 없는, 상류 원문 보존 파일이 물려받은
|
||||
# 죽은 링크만 넣는다(우리 저장소가 큐레이션한 링크가 깨지면 allowlist 가 아니라 고친다).
|
||||
# 새 항목을 넣을 때는 반드시 "왜 고칠 수 없는가"를 주석으로 남긴다.
|
||||
#
|
||||
# 형식: 한 줄에 URL 하나. `#` 뒤는 주석, 빈 줄·주석 전용 줄은 무시.
|
||||
|
||||
# 상류(Autumn-27/ARTEX) CHANGELOG.zh.md:539 가 크레딧한 기여자 GitHub 프로필.
|
||||
# 계정이 개명·삭제돼 404. 상류 원문 보존 방침(BRIEF 경계 #1·MAINTAINING 2절)상
|
||||
# CHANGELOG.zh.md 는 상류 그대로 두므로 우리가 고치지 않는다. 2026-10-07 확인.
|
||||
https://github.com/begininvoke
|
||||
Reference in New Issue
Block a user