instruction.md
# Korean Law Search
## What this skill does
기본적으로 `https://k-skill-proxy.nomadamas.org/v1/korean-law/...` 로 요청해서 한국 법령/조문/판례/유권해석/자치법규를 조회한다. 법제처(국가법령정보센터) 공식 Open API(`open.law.go.kr` 의 DRF `lawSearch.do`/`lawService.do`)를 기반으로 하며, 설계는 `chrisryugj/korean-law-mcp` 의 read-only 도구 표면을 참고했다.
사용자는 별도 API key(`LAW_OC`)나 로컬 CLI 설치가 필요 없다. `LAW_OC` 와 브라우저 User-Agent/Referer 주입은 proxy 서버에서만 처리한다.
- 검색/목록: `GET /v1/korean-law/search`
- 본문/상세: `GET /v1/korean-law/detail`
- 신・구조문대비표 작성: `scripts/amendment_table.js` (현행 조문은 위 `search`/`detail` 로 자동 조회, 한글 `.hwpx` 저장)
## When to use
- "산업안전보건법 찾아줘"
- "관세법 제38조 보여줘"
- "부당해고 판례 찾아줘"
- "개인정보보호법 시행령 조문 확인해줘"
- "한국 법령/판례/자치법규 검색해줘"
- "건축법 제22조 개정안 신구조문대비표 만들어줘"
- "이 개정안으로 신구조문대비표 한글 파일로 저장해줘"
## When not to use
- 미국/일본/EU 등 비한국 법령 검색
- 실제 법률 자문·소송 전략을 단정적으로 제공해야 하는 경우
- 법령 원문이 아니라 일반 상식 설명만 필요한 경우
## Prerequisites
없음. 사용자는 별도 API key를 준비할 필요가 없다. upstream `LAW_OC` 는 proxy 서버에서만 주입한다.
## Default path
추가 client API 레이어는 불필요하다. 그냥 프록시 서버에 HTTP 요청만 넣으면 된다.
`KSKILL_PROXY_BASE_URL` 환경변수가 있으면 그 값을 사용하고, 없으면 기본 경로 `https://k-skill-proxy.nomadamas.org` 를 사용한다.
## Supported endpoints
### 검색/목록 조회
```
GET /v1/korean-law/search?target={target}&query={검색어}
```
`target` 은 read-only 법령정보 종류다.
| target | 설명 |
|---|---|
| `law` | 현행법령 |
| `eflaw` | 시행일 법령 |
| `elaw` | 영문법령 |
| `prec` | 판례 |
| `detc` | 헌재결정례 |
| `expc` | 법령해석례(유권해석) |
| `admrul` | 행정규칙 |
| `ordin` | 자치법규 |
| `trty` | 조약 |
| `lstrm` | 법령용어 |
지원 필터: `query`(검색어), `display`, `page`, `sort`, `date`, `prncYd`(선고일자), `nb`(사건번호), `datSrcNm`(데이터출처명), `curt`(법원), `org`, `knd`, `gana`, `nw`, `efYd`, `ancYd`. 응답은 법제처 DRF JSON 그대로에 `proxy` 메타데이터만 덧붙인다. 요약 전에 반환 메타데이터를 먼저 확인한다.
### 본문/상세 조회
```
GET /v1/korean-law/detail?target={target}&ID={일련번호}
```
검색 결과의 식별자(`ID` 또는 `MST`/`LID`)를 넘겨 상세 본문을 가져온다. 조문 지정은 `JO`(예: `000200` = 제2조), 언어는 `LANG` 로 넘긴다.
## Example requests
법령명 검색:
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-law/search' \
--data-urlencode 'target=law' \
--data-urlencode 'query=관세법'
```
판례 검색:
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-law/search' \
--data-urlencode 'target=prec' \
--data-urlencode 'query=부당해고'
```
판례 본문 조회:
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-law/detail' \
--data-urlencode 'target=prec' \
--data-urlencode 'ID=228541'
```
## Response policy
- 한국 법령 관련 요청은 이 proxy endpoint로 처리한다. 별도 크롤러나 검색엔진 우회로 넘어가지 않는다.
- 약칭(`화관법`)이면 `target=law` 로 정식 법령명을 먼저 확인한다.
- 조문 요청이면 검색 결과의 식별자(`MST`/`ID`)를 확인한 뒤 `detail` 로 본문을 가져온다.
- 판례는 `target=prec`, 유권해석은 `target=expc`, 자치법규는 `target=ordin` 로 조회한다.
- 판례 본문이 필요하면 검색 결과의 판례 일련번호를 `detail?target=prec&ID=...` 로 이어서 조회한다.
- 검색 결과가 0건이어도 "관련 규범이 없다"고 단정하지 말고 검색어·법원·사건번호·선고일자·출처명을 바꿔 다시 시도한다.
- 일부 출처는 본문을 제공하지 않을 수 있다. 본문을 못 가져오면 목록 메타데이터(사건번호·법원·선고일자·출처·요지)까지만 제공하고 본문이 없다는 점을 명시한다(없는 본문을 지어내지 않는다).
- 법적 판단이 필요한 경우 `검색 결과 요약`과 `원문 출처`까지만 제공하고 법률 자문처럼 단정하지 않는다.
## 신・구조문대비표 작성
개정할 조문만 주면 현행 조문을 법제처에서 가져와 대조한 **신・구조문대비표 초안**을 만든다. 바뀐 부분은 밑줄(채팅 표에서는 굵은 글씨), 개정안 칸의 같은 부분은 `----`, 바뀌지 않은 항·호는 현행 칸 `(생 략)` / 개정안 칸 `(현행과 같음)`, 신설·삭제는 `<신 설>` / `<삭 제>` 로 적는다. 표기 규칙은 법제처 신구법비교 원문을 따른다.
1. 법령명과 개정할 조문을 확인한다. 사용자가 **개정 후 조문 전문**을 주지 않았으면 `detail` 로 현행 조문을 읽고, 사용자가 말한 개정 내용만 반영해 개정 후 조문 전문을 쓴다. 사용자가 말하지 않은 내용은 바꾸지 않는다.
2. 아래 JSON 을 파일(또는 stdin)로 만든다.
```json
{
"lawName": "건축법",
"title": "건축법 일부개정법률안",
"amendments": [
{ "jo": "제22조", "text": "제22조(건축물의 사용승인) ① …개정 후 모습 그대로…\n② ∼ ⑥ (현행과 같음)" }
]
}
```
- `text`: 개정 후 모습 그대로 조 머리·항·호·목을 한 줄에 한 단위로 쓴다. 바뀌지 않는 항·호는 그대로 적거나 `② ∼ ⑤ (현행과 같음)` · `1.·2. (현행과 같음)` 으로 줄여 쓴다. **적지 않은 현행 항·호는 삭제로 본다.** 지우는 항·호는 `③ 삭제`, 조 전체 삭제는 `제5조 삭제`.
- `jo`: 생략하면 `text` 첫머리의 조 번호를 쓴다. 한 번에 30개 조문까지, 같은 조는 한 번만 넣는다.
- `lawName`(정확한 법령명) 또는 `mst`/`lawId`. 조례·행정규칙처럼 자동 조회가 안 되는 현행은 조문마다 `currentText` 에 현행 원문을 직접 넣는다(신설 조는 빈 문자열). 모든 조문에 `currentText` 가 있으면 법령을 조회하지 않는다.
3. 실행한다. 한글 파일이 필요하면 `--output` 에 `.hwpx` 경로를 준다(`~/` 는 홈 디렉터리). 한글 파일은 사용자가 달라고 했을 때만 만든다.
```bash
npx -y @nomadamas/k-skill@0 exec korean-law-search scripts/amendment_table.js -- --input amendment.json --output ~/Desktop/건축법_신구조문대비표.hwpx
```
4. stdout 의 표와 `확인할 점` 을 그대로 사용자에게 보여 준다. `.hwpx` 를 저장했으면 저장 경로를 알린다. 표는 직접 고쳐 쓰지 않는다(`----` 개수·밑줄이 틀어진다) — 고칠 곳이 있으면 JSON 을 고쳐 다시 실행한다.
5. 결과는 **초안**이다. 공포·제출 전에 법제처 원문과 대조해 검수해야 한다고 알린다.
## Failure modes
- `target` 이 없거나 허용되지 않은 값이면 400 응답
- 검색어/식별자가 없으면 400 응답
- 프록시 서버에 `LAW_OC` 가 없으면 503 응답
- 법제처 API가 사용자 검증 실패(`사용자 정보 검증 실패`)를 반환하면 502 + `law_user_verification_failed` (서버 OC/UA/Referer 점검)
- 법제처 API가 일시적으로 빈/HTML 응답이면 proxy가 재시도 후 502 + `upstream_unstable`
- 대비표 `[NOT_FOUND] 법령명이 정확히 같은 법령을 찾지 못했습니다` → 검색 후보를 보여 주고 정확한 법령명을 다시 받거나 `currentText` 를 쓴다. 부분 일치(예: 「민법」→「난민법」)로 넘어가지 않는다.
- 대비표 `[UPSTREAM]` (proxy 연결 실패·법제처 점검) → 잠시 뒤 다시 실행한다. 표를 손으로 만들어 대신하지 않는다.
- 대비표 `[INVALID_PARAM]` (JSON 형식·`.hwpx` 확장자·중복 조문·31개 이상) → 메시지대로 JSON 을 고쳐 다시 실행한다.
- 대비표 `[NO_CHANGE]` → 개정안이 현행과 모두 같다는 뜻이다. 입력한 개정 내용을 사용자와 확인한다.
## Done when
- 한국 법령 관련 질의를 proxy endpoint로 라우팅했다.
- 법령/조문은 `target=law` + 필요 시 `detail`, 판례는 `target=prec`, 유권해석은 `target=expc`, 자치법규는 `target=ordin` 로 맞는 종류를 조회했다.
- 판례/조문 본문이 필요하면 식별자로 `detail` 본문까지 연결했다.
- 결과를 요약하고 원문 출처(법제처 국가법령정보센터)를 함께 남겼다.
- 신・구조문대비표 요청이면 `scripts/amendment_table.js` 결과(표·`확인할 점`)를 그대로 전달했고, 한글 파일을 요청받았다면 저장 경로를 알렸으며, 초안이라 검수가 필요하다고 안내했다.
## Notes
- 설계 참고(upstream): `https://github.com/chrisryugj/korean-law-mcp`
- official data source: 법제처 Open API (`https://open.law.go.kr`, DRF `lawSearch.do`/`lawService.do`)
- 운영자(proxy) 전용 시크릿: `LAW_OC` (사용자는 불필요). 무료 발급: `https://open.law.go.kr`
- 신・구조문대비표 엔진(`scripts/amendment/`)은 `chrisryugj/korean-law-mcp`(MIT)의 `amendment_table` 을 이식했다. 현행 조문 조회만 사용자 키가 필요 없는 k-skill-proxy 호출로 바꿨고, 라이선스 전문은 `scripts/amendment/LICENSE-korean-law-mcp` 에 둔다.
- 이 저장소 안에는 한국 법령 전용 npm package나 python package를 추가하지 않는다.
scripts/amendment_table.js
#!/usr/bin/env node
"use strict";
// 신・구조문대비표 작성기. 개정안 조문 JSON 을 받아 현행 조문(k-skill-proxy 경유 법제처)과 대조한 표를
// stdout 에 쓰고, --output 이면 한글(.hwpx) 파일로도 저장한다. LAW_OC 같은 사용자 키는 필요 없다.
//
// 입력 JSON: { lawName, mst?, lawId?, title?, amendments: [{ jo?, text, currentText? }] }
// 엔진은 chrisryugj/korean-law-mcp(MIT)에서 이식했다 — korean-law-search/NOTICE 참고.
const fs = require("node:fs");
const { parseArgs } = require("node:util");
const { amendmentTable } = require("./amendment/table.js");
const { createProxyClient } = require("./amendment/current.js");
const USAGE = `사용법: amendment_table.js [--input 개정안.json] [--output 대비표.hwpx]
--input 개정안 JSON 파일 (생략하면 stdin)
--output 한글(.hwpx) 저장 경로 ("~/" 는 홈 디렉터리)
KSKILL_PROXY_BASE_URL 로 proxy 주소를 바꿀 수 있다.`;
async function main(argv) {
let args;
try {
args = parseArgs({ args: argv, options: { input: { type: "string", short: "i" }, output: { type: "string", short: "o" }, help: { type: "boolean", short: "h" } } }).values;
} catch (error) {
process.stderr.write(`${error.message}\n${USAGE}\n`);
return 2;
}
if (args.help) {
process.stdout.write(`${USAGE}\n`);
return 0;
}
let raw;
try {
raw = args.input ? fs.readFileSync(args.input, "utf8") : fs.readFileSync(0, "utf8");
} catch (error) {
process.stderr.write(`입력을 읽지 못했습니다: ${error.message}\n`);
return 2;
}
let input;
try {
input = JSON.parse(raw);
} catch (error) {
process.stderr.write(`입력이 올바른 JSON 이 아닙니다: ${error.message}\n`);
return 2;
}
if (args.output && input && typeof input === "object") input.outputPath = args.output;
const result = await amendmentTable(createProxyClient(), input);
(result.isError ? process.stderr : process.stdout).write(`${result.text}\n`);
return result.isError ? 1 : 0;
}
main(process.argv.slice(2)).then(
(code) => { process.exitCode = code; },
(error) => { process.stderr.write(`예기치 못한 오류: ${error.stack || error}\n`); process.exitCode = 1; },
);
scripts/amendment/align.js
"use strict";
/**
* 신구조문대비표 — 현행·개정안 단위(항·호·목) 짝짓기
*
* 대비표는 현행을 기준으로 개정 부분을 적는다. 법제처 신구법비교 원문(3,795쌍)은 번호가 같으면
* 내용이 통째로 바뀌어도 그 번호끼리 짝을 짓고, 남는 번호만 <신 설>·<삭 제>로 둔다
* (가운데 항을 끼워 번호가 밀려도 "(현행 제N항과 같음)" 0건). 그래서 번호 일치가 언제나 이기게
* 가산점을 유사도(≤1)보다 크게 두고, 번호가 어긋난 단위끼리는 내용이 비슷할 때만 짝을 짓는다.
*/
const { normalizeForCompare } = require("./units.js");
const LABEL_BONUS = 2;
const MIN_SIM_UNLABELED = 0.5;
function grams(s) {
const norm = normalizeForCompare(s);
const map = new Map();
const t = norm.replace(/\s/g, "");
for (let i = 0; i < t.length - 1; i++) {
const g = t.slice(i, i + 2);
map.set(g, (map.get(g) ?? 0) + 1);
}
return { map, total: Math.max(0, t.length - 1), norm };
}
/** 글자 바이그램 Dice 계수 (0~1) — 작은 쪽 표만 돈다 */
function dice(a, b) {
if (a.norm === b.norm)
return 1;
if (!a.total || !b.total)
return 0;
const [small, big] = a.map.size <= b.map.size ? [a, b] : [b, a];
let inter = 0;
for (const [g, v] of small.map)
inter += Math.min(v, big.map.get(g) ?? 0);
return (2 * inter) / (a.total + b.total);
}
/** 자손까지 이은 본문 — 머리글이 같고 호만 바뀐 항도 같은 짝으로 잡히게 */
function fullText(u) {
return [u.body, ...u.children.map(fullText)].join(" ");
}
/** 유사도 계산 짝 수 상한 — 넘으면 표 없이 번호만으로 선형 짝짓기(수천 줄 입력의 메모리·시간 보호) */
const MAX_SCORED_PAIRS = 10_000;
const keyOf = (u) => `${u.kind}:${u.label}`;
/** 번호(같은 층 같은 라벨)만으로 순서대로 짝 — O(n + m) */
function alignByLabel(a, b) {
const pos = new Map();
b.forEach((u, k) => {
const e = pos.get(keyOf(u)) ?? { at: [], p: 0 };
e.at.push(k);
pos.set(keyOf(u), e);
});
const out = [];
let j = 0;
for (const x of a) {
const e = pos.get(keyOf(x));
while (e && e.p < e.at.length && e.at[e.p] < j)
e.p++;
const k = e && e.p < e.at.length ? e.at[e.p++] : undefined;
if (k === undefined) {
out.push({ old: x, children: [] });
continue;
}
for (; j < k; j++)
out.push({ new: b[j], children: [] });
out.push({ old: x, new: b[k], children: alignUnits(x.children, b[k].children) });
j = k + 1;
}
for (; j < b.length; j++)
out.push({ new: b[j], children: [] });
return out;
}
function alignUnits(a, b) {
const n = a.length, m = b.length;
if (n * m > MAX_SCORED_PAIRS)
return alignByLabel(a, b);
const ga = a.map(u => [grams(u.body), grams(fullText(u))]), gb = b.map(u => [grams(u.body), grams(fullText(u))]);
const pairScore = (i, j) => {
if (a[i].kind !== b[j].kind)
return -1;
const sim = Math.max(dice(ga[i][0], gb[j][0]), dice(ga[i][1], gb[j][1]));
if (a[i].label === b[j].label)
return LABEL_BONUS + sim;
return sim >= MIN_SIM_UNLABELED ? sim : -1;
};
const score = a.map((_, i) => b.map((_, j) => pairScore(i, j)));
const dp = Array.from({ length: n + 1 }, () => new Float64Array(m + 1));
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
const sc = score[i][j];
dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1], sc >= 0 ? dp[i + 1][j + 1] + sc : -Infinity);
}
}
const out = [];
let i = 0, j = 0;
while (i < n || j < m) {
const sc = i < n && j < m ? score[i][j] : -1;
if (sc >= 0 && dp[i][j] === dp[i + 1][j + 1] + sc) {
out.push({ old: a[i], new: b[j], children: alignUnits(a[i].children, b[j].children) });
i++;
j++;
}
else if (i < n && (j >= m || dp[i][j] === dp[i + 1][j])) {
out.push({ old: a[i], children: [] });
i++;
}
else {
out.push({ new: b[j], children: [] });
j++;
}
}
return out;
}
const bodyEq = (n) => normalizeForCompare(n.old.body) === normalizeForCompare(n.new.body);
/** 현행과 같음 — 번호·본문·자손 전부 같다 */
function isSame(n) {
return !!n.old && !!n.new && n.old.label === n.new.label && bodyEq(n) && n.children.every(isSame);
}
/** 개정안이 "③ 삭제" 로 지운 단위 */
function isDeletedInPlace(n) {
return !!n.old && !!n.new && /^삭제\.?$/.test(n.new.body) && !/^삭제\.?$/.test(n.old.body);
}
module.exports = { alignUnits, isSame, isDeletedInPlace };
scripts/amendment/current.js
"use strict";
/**
* 신구조문대비표 — 현행 조문 조회.
*
* k-skill-proxy 의 /v1/korean-law/search · /detail (법제처 DRF 그대로 전달) 만 쓴다.
* 사용자 LAW_OC 는 필요 없고, 조회는 read-only 다.
*/
const { AmendmentError } = require("./errors.js");
const { buildJO, cleanHtml, extractHangContent, flattenContent } = require("./law-json.js");
const DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org";
const REQUEST_TIMEOUT_MS = 30000;
/**
* @param {{ baseUrl?: string, fetchImpl?: typeof fetch, timeoutMs?: number }} [options]
*/
function createProxyClient({ baseUrl, fetchImpl = globalThis.fetch, timeoutMs = REQUEST_TIMEOUT_MS } = {}) {
const base = (baseUrl || process.env.KSKILL_PROXY_BASE_URL || DEFAULT_PROXY_BASE_URL).replace(/\/+$/, "");
async function getJson(endpoint, params) {
const url = new URL(`${base}/v1/korean-law/${endpoint}`);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
let response;
let text;
try {
response = await fetchImpl(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(timeoutMs) });
text = await response.text();
} catch (error) {
throw new AmendmentError(`k-skill-proxy 에 연결하지 못했습니다 (${base}): ${error.cause?.code || error.message}`, "UPSTREAM");
}
let json;
try {
json = JSON.parse(text);
} catch {
throw new AmendmentError(`proxy 응답이 JSON 이 아닙니다 (HTTP ${response.status}) — 법제처가 일시적으로 점검 중일 수 있습니다.`, "UPSTREAM");
}
if (!response.ok) {
const code = json?.error || "error";
throw new AmendmentError(`proxy ${response.status} ${code}${json?.message ? `: ${json.message}` : ""}`, "UPSTREAM");
}
return json;
}
return {
/** 법령명 검색 → [{lawName, lawId, mst, status}] (DRF 는 결과가 1건이면 배열 대신 객체를 준다) */
async searchLaws(query) {
const json = await getJson("search", { target: "law", query, type: "JSON", display: "20" });
const raw = json?.LawSearch?.law;
const laws = raw ? (Array.isArray(raw) ? raw : [raw]) : [];
return laws.map((l) => ({
lawName: String(l.법령명한글 ?? ""),
lawId: String(l.법령ID ?? ""),
mst: String(l.법령일련번호 ?? ""),
status: String(l.현행연혁코드 ?? ""),
}));
},
/** 법령(또는 그 한 조) 본문 JSON */
async getLawJson({ mst, lawId, jo }) {
const params = { target: "law", type: "JSON" };
if (mst) params.MST = mst;
else if (lawId) params.ID = lawId;
if (jo) params.JO = jo;
return getJson("detail", params);
},
};
}
// 법령명 비교는 공백·가운뎃점 표기 차이를 무시한다 (법제처 공식 표기는 'ㆍ', 실무 문서는 '·')
const normalizeName = (s) => s.replace(/\s+/g, "").replace(/[·ㆍ‧•・]/g, "");
/**
* 법령명 → mst. 이름이 정확히 같은 법령만 받아들인다 — 법제처 검색은 부분 일치라
* 「민법」을 찾으면 「난민법」이 나오므로, 첫 결과를 고르면 엉뚱한 법령과 대조하게 된다.
* mst·lawId 가 있으면 조회하지 않는다.
*/
async function resolveLaw(client, input) {
if (input.mst || input.lawId) return { lawName: input.lawName ?? "", mst: input.mst, lawId: input.lawId };
const name = input.lawName?.trim();
if (!name) throw new AmendmentError("lawName·mst·lawId 중 하나가 필요합니다.", "INVALID_PARAM");
const laws = await client.searchLaws(name);
const same = laws.filter((l) => normalizeName(l.lawName) === normalizeName(name));
const hit = same.find((l) => l.status === "현행") ?? same[0];
if (!hit) {
const candidates = laws.slice(0, 5).map((l) => `「${l.lawName}」`).join(", ");
throw new AmendmentError(`법령명이 정확히 같은 법령을 찾지 못했습니다: ${name}${candidates ? ` (검색 후보: ${candidates})` : ""}`, "NOT_FOUND", [
"정확한 법령명(예: '건축법 시행령')으로 다시 호출하거나, 조례·행정규칙이면 amendments[].currentText 에 현행 원문을 넣으세요.",
]);
}
return { lawName: hit.lawName, mst: hit.mst, lawId: hit.lawId };
}
/** 현행 조문 텍스트. 그 조가 없으면 null(신설 조) */
async function fetchCurrentArticle(client, law, jo) {
const code = buildJO(jo);
const json = await client.getLawJson({ mst: law.mst, lawId: law.lawId, jo: code });
const raw = json?.법령?.조문?.조문단위;
const units = raw ? (Array.isArray(raw) ? raw : [raw]) : [];
// 업스트림이 JO 를 무시하고 다른 조(법령 전체)를 주는 일이 있다 — 번호까지 맞는 조만 현행으로 쓴다
const num = Number(code.slice(0, 4));
const branch = Number(code.slice(4));
const unit = units.find((u) => u?.조문여부 === "조문" && Number(u.조문번호) === num && Number(u.조문가지번호 || 0) === branch);
if (!unit) return null;
const head = flattenContent(unit.조문내용).trim();
// "[제44조의2는 제43조로 이동]" 같은 자리표시 조문은 번호만 있고 본문이 없다 — 그 번호의 조는 없다
if (/^제\d+조(?:의\d+)?$/.test(head) && !unit.항) return null;
const rest = unit.항 ? extractHangContent(unit.항) : "";
return cleanHtml([head, rest].filter(Boolean).join("\n"));
}
module.exports = { DEFAULT_PROXY_BASE_URL, createProxyClient, fetchCurrentArticle, resolveLaw };
scripts/amendment/diff.js
"use strict";
/**
* 신구조문대비표 — 한 단위 본문의 현행·개정안 문장 비교 → 밑줄 구간
*
* 법제처 신구법비교 원문의 `<P>` 밑줄 구간에서 읽은 규칙:
* - 밑줄은 어절 단위다(글자 단위로 공통부분을 깎지 않는다).
* - 끼워 넣기·빼기만 있으면 이웃 어절을 붙잡아 양쪽에 다 긋는다("<u>공공장소</u>" →
* "<u>공공장소, 동물병원 …</u>"). 개정문이 「A」를 「A, B」로 쓰는 것과 같은 단위다.
* - 덩어리 끝의 같은 조사는 밑줄 밖에 둔다("<u>공사완료도서</u>를").
*/
const { splitEdges } = require("./units.js");
/** 어절(+뒤 공백) 토큰 — 여는 괄호 앞에서도 끊는다("특별자치도지사(이하" → 두 토큰). 이어 붙이면 원문 */
function tokenize(s) {
return s.match(/[^\s([「“]+\s*|[([「“][^\s([「“]*\s*|\s+/g) ?? [];
}
/** LCS 표 칸 수 상한(4M칸 = 16MB). 넘으면 앞뒤 같은 어절만 떼는 선형 비교로 — 원격 서버 메모리 보호 */
const MAX_LCS_CELLS = 4_000_000;
function lcsOps(a, b) {
const n = a.length, m = b.length;
const key = (t) => t.trimEnd();
if ((n + 1) * (m + 1) > MAX_LCS_CELLS) {
let p = 0, q = 0;
while (p < n && p < m && key(a[p]) === key(b[p]))
p++;
while (q < n - p && q < m - p && key(a[n - 1 - q]) === key(b[m - 1 - q]))
q++;
return [
{ kind: "eq", a: a.slice(0, p), b: b.slice(0, p) },
{ kind: "chg", a: a.slice(p, n - q), b: b.slice(p, m - q) },
{ kind: "eq", a: a.slice(n - q), b: b.slice(m - q) },
].filter(op => op.a.length || op.b.length);
}
const dp = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
dp[i][j] = key(a[i]) === key(b[j]) ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
const ops = [];
const push = (kind, x, y) => {
let last = ops[ops.length - 1];
if (!last || last.kind !== kind) {
last = { kind, a: [], b: [] };
ops.push(last);
}
if (x !== undefined)
last.a.push(x);
if (y !== undefined)
last.b.push(y);
};
let i = 0, j = 0;
while (i < n || j < m) {
if (i < n && j < m && key(a[i]) === key(b[j])) {
push("eq", a[i], b[j]);
i++;
j++;
}
else if (j < m && (i >= n || dp[i][j + 1] >= dp[i + 1][j])) {
push("chg", undefined, b[j]);
j++;
}
else {
push("chg", a[i]);
i++;
}
}
return ops;
}
/** 끼워 넣기·빼기만 있는 덩어리에 앞(없으면 뒤) 어절 하나를 붙인다 */
function anchorPureEdits(ops) {
for (let k = 0; k < ops.length; k++) {
const op = ops[k];
if (op.kind !== "chg" || (op.a.length && op.b.length))
continue;
const prev = ops[k - 1], next = ops[k + 1];
if (prev?.kind === "eq" && prev.a.length) {
op.a.unshift(prev.a.pop());
op.b.unshift(prev.b.pop());
}
else if (next?.kind === "eq" && next.a.length) {
op.a.push(next.a.shift());
op.b.push(next.b.shift());
}
}
return ops.filter(op => op.a.length || op.b.length);
}
const JOSA = ["으로써", "로써", "으로서", "로서", "에서는", "에서", "에게", "에는", "으로", "부터", "까지", "이나",
"로", "을", "를", "이", "가", "은", "는", "의", "에", "와", "과", "도", "만"];
/** 양쪽 덩어리 끝의 같은 문장부호(, .)·조사는 떼어 밑줄 밖으로 */
function splitJosa(a, b) {
let ta = a.trimEnd(), tb = b.trimEnd();
let tail = a.slice(ta.length);
const p = ta.match(/[,.]+$/)?.[0];
if (p && tb.endsWith(p) && ta.length > p.length && tb.length > p.length) {
ta = ta.slice(0, -p.length);
tb = tb.slice(0, -p.length);
tail = p + tail;
}
for (const j of JOSA) {
if (ta.length <= j.length || tb.length <= j.length || !ta.endsWith(j) || !tb.endsWith(j))
continue;
const ca = ta[ta.length - j.length - 1], cb = tb[tb.length - j.length - 1];
if (/[\d\s]/.test(ca) || /[\d\s]/.test(cb))
continue;
return { a: ta.slice(0, -j.length), b: tb.slice(0, -j.length), suf: j + tail };
}
return { a: ta, b: tb, suf: tail };
}
function pushSeg(segs, text, u) {
if (!text)
return;
const last = segs[segs.length - 1];
if (last && last.u === u)
last.text += text;
else
segs.push({ text, u });
}
/** 밑줄 구간 양 끝 공백은 밑줄 밖으로 뺀다 */
function liftSpaces(segs) {
const out = [];
for (const s of segs) {
if (!s.u) {
pushSeg(out, s.text, false);
continue;
}
const [lead, core, tail] = splitEdges(s.text);
pushSeg(out, lead, false);
pushSeg(out, core, true);
pushSeg(out, tail, false);
}
return out;
}
/** 바뀐 덩어리 사이에 낀 같은 어절이 MERGE_GAP 개 이하면 한 덩어리로 잇는다 */
const MERGE_GAP = 1;
function mergeNear(ops) {
const out = [];
for (const op of ops) {
const prev = out[out.length - 1], before = out[out.length - 2];
if (op.kind === "chg" && prev?.kind === "eq" && before?.kind === "chg" && prev.a.length <= MERGE_GAP) {
out.pop();
before.a.push(...prev.a, ...op.a);
before.b.push(...prev.b, ...op.b);
continue;
}
out.push(op);
}
return out;
}
/**
* 같은 글자가 이 비율도 안 남으면 통째로 바뀐 단위로 본다 — 우연히 겹친 "또는"·"때" 섬을 남기지 않는다.
* MERGE_GAP·WHOLE_RATIO 는 신구법비교 원문 93개 법령 838쌍으로 고르고 다른 100개 법령 624쌍에서
* 확인한 값이다(글자 F1 0.955 / 0.947, 2026-10-31).
*/
const WHOLE_RATIO = 0.3;
const visible = (s) => s.replace(/\s/g, "").length;
function diffInline(oldText, newText) {
const ops = mergeNear(anchorPureEdits(lcsOps(tokenize(oldText), tokenize(newText))));
const same = ops.filter(op => op.kind === "eq").reduce((n, op) => n + visible(op.a.join("")), 0);
if (same < WHOLE_RATIO * Math.max(visible(oldText), visible(newText))) {
return { old: liftSpaces([{ text: oldText, u: true }]), new: liftSpaces([{ text: newText, u: true }]) };
}
const oldSegs = [], newSegs = [];
for (const op of ops) {
const a = op.a.join(""), b = op.b.join("");
if (op.kind === "eq") {
pushSeg(oldSegs, a, false);
pushSeg(newSegs, b, false);
continue;
}
const t = splitJosa(a, b);
pushSeg(oldSegs, t.a, true);
pushSeg(newSegs, t.b, true);
pushSeg(oldSegs, t.suf, false);
pushSeg(newSegs, t.suf, false);
}
return { old: liftSpaces(oldSegs), new: liftSpaces(newSegs) };
}
module.exports = { diffInline };
scripts/amendment/errors.js
"use strict";
/** 입력 오류·조회 실패를 코드와 함께 알리는 오류. CLI 와 amendmentTable 이 같은 형식으로 출력한다. */
class AmendmentError extends Error {
/**
* @param {string} message
* @param {string} code INVALID_PARAM | NOT_FOUND | UPSTREAM
* @param {string[]} [hints]
*/
constructor(message, code, hints = []) {
super(message);
this.name = "AmendmentError";
this.code = code;
this.hints = hints;
}
}
module.exports = { AmendmentError };
scripts/amendment/format.js
"use strict";
const PUNCT = /[\p{P}\p{S}]/u;
const SPACE = /\s/;
/**
* 굵게 감쌀 구간 [a, b) 을 CommonMark 강조 규칙에 맞게 다듬는다. "제65조**(승무정원)**"처럼 글자와 문장부호가
* 맞붙은 경계에선 ** 가 강조로 안 읽혀 글자째 남는다 — 경계의 문장부호를 굵은 범위 밖으로 뺀다.
*/
function fitBold(line, a, b) {
const opens = (i) => !PUNCT.test(line[i]) || i === 0 || SPACE.test(line[i - 1]) || PUNCT.test(line[i - 1]);
const closes = (j) => !PUNCT.test(line[j - 1]) || j === line.length || SPACE.test(line[j]) || PUNCT.test(line[j]);
while (a < b && !opens(a))
a++;
while (b > a && !closes(b))
b--;
// 여는 괄호가 밖으로 나갔으면 짝 닫는 괄호도 밖으로: "(**승무정원)**" → "(**승무정원**)"
const pair = { ")": "(", "]": "[", "」": "「", "”": "“" };
const inside = (ch) => [...line.slice(a, b)].filter(c => c === ch).length;
while (b > a && pair[line[b - 1]] && inside(pair[line[b - 1]]) < inside(line[b - 1]))
b--;
return [a, b];
}
function lineToMarkdown(line) {
const text = line.map(s => s.text).join("");
const spans = [];
let at = 0;
for (const s of line) {
const end = at + s.text.length;
if (s.u && s.text.trim()) {
const prev = spans[spans.length - 1];
if (prev && prev[1] === at)
prev[1] = end;
else
spans.push([at, end]);
}
at = end;
}
const esc = (t) => t.replace(/([|*\\])/g, "\\$1");
let out = "", pos = 0;
for (const [a0, b0] of spans) {
const [a, b] = fitBold(text, a0, b0);
if (a >= b)
continue;
out += esc(text.slice(pos, a)) + "**" + esc(text.slice(a, b)) + "**";
pos = b;
}
return out + esc(text.slice(pos));
}
function rowsToMarkdown(title, rows) {
const cell = (lines) => lines.map(lineToMarkdown).join("<br>");
return [
`### ${title}`,
"",
"| 현 행 | 개 정 안 |",
"| --- | --- |",
...rows.map(r => `| ${cell(r.old)} | ${cell(r.new)} |`),
].join("\n");
}
/** 줄 단위 평문 — 밑줄 구간을 ‹›로 감싼다(표를 못 그리는 화면·검수용) */
function lineToPlain(line) {
return line.map(s => s.u ? `‹${s.text}›` : s.text).join("").replace(/›‹/g, "");
}
module.exports = { rowsToMarkdown, lineToPlain };
scripts/amendment/hwpx-header.js
"use strict";
/**
* 신구조문대비표 HWPX — 패키지 고정 파일 + header.xml (글꼴·글자·문단·테두리 정의)
*
* 서식은 법제처 「법제업무편람」 법령심사안 작성 방법을 따른다:
* 신명조(한양신명조 HFT) 14pt, 대비표 줄 간격 180%, 제목 16pt 진하게 가운데,
* 조·항·호·목 내어 쓰기 14 · 14 · 24.4 · 45.4 · 54.2 · 75.2pt.
* 한글 줄바꿈은 글자 단위 — 한컴 XML 에서 breakNonLatinWord="KEEP_WORD" 가 글자 단위다(이름과 반대,
* kordoc 한글 실렌더 실측). 어절 단위면 좁은 대비표 칸이 양쪽 정렬로 크게 벌어진다(한글 화면 확인).
* 본문 정렬은 왼쪽 — 줄표 덩어리(라틴 단어라 안 쪼개짐)가 다음 줄로 넘어가면 남은 짧은 줄이 양쪽 정렬로
* "설 및 공동주택", "- - - -"처럼 벌어졌다(한글 화면 확인). 한글은 글자 단위라 왼쪽이어도 줄이 꽉 찬다.
*/
const NS_HEAD = "http://www.hancom.co.kr/hwpml/2011/head";
const NS_PARA = "http://www.hancom.co.kr/hwpml/2011/paragraph";
const NS_CORE = "http://www.hancom.co.kr/hwpml/2011/core";
const CONTAINER_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
<ocf:container xmlns:ocf="urn:oasis:names:tc:opendocument:xmlns:container" xmlns:hpf="http://www.hancom.co.kr/schema/2011/hpf">
<ocf:rootfiles>
<ocf:rootfile full-path="Contents/content.hpf" media-type="application/hwpml-package+xml"/>
</ocf:rootfiles>
</ocf:container>`;
const CONTENT_HPF = `<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
<opf:package xmlns:opf="http://www.idpf.org/2007/opf/" xmlns:hpf="http://www.hancom.co.kr/schema/2011/hpf" xmlns:hh="${NS_HEAD}">
<opf:metadata>
<opf:meta name="generator" content="korean-law-mcp"/>
</opf:metadata>
<opf:manifest>
<opf:item id="header" href="Contents/header.xml" media-type="application/xml"/>
<opf:item id="section0" href="Contents/section0.xml" media-type="application/xml"/>
</opf:manifest>
<opf:spine>
<opf:itemref idref="header" linear="no"/>
<opf:itemref idref="section0" linear="yes"/>
</opf:spine>
</opf:package>`;
/** 글자 모양 id */
const CHAR = { body: 0, underline: 1, title: 2 };
/** 문단 모양 id — 내어쓰기 단계는 BODY_LEVEL + 단계(0 조 · 1 항 · 2 호 · 3 목 · 4 1) · 5 가)) */
const PARA = { title: 0, center: 1, bodyLevel: 2 };
const HANG_PT = [14, 14, 24.4, 45.4, 54.2, 75.2];
/** 셀 테두리 id: 좌우는 늘 실선, 위·아래는 조문 경계에서만 */
const BORDER = { none: 1, topBottom: 2, top: 3, open: 4, bottom: 5 };
const LANGS = ["HANGUL", "LATIN", "HANJA", "JAPANESE", "OTHER", "SYMBOL", "USER"];
const FONT = "한양신명조";
function fontfaces() {
const face = (lang) => ` <hh:fontface lang="${lang}" fontCnt="1">
<hh:font id="0" face="${FONT}" type="HFT" isEmbedded="0"/>
</hh:fontface>`;
return ` <hh:fontfaces itemCnt="${LANGS.length}">\n${LANGS.map(face).join("\n")}\n </hh:fontfaces>`;
}
function borderFill(id, top, bottom, sides) {
const line = (on) => on ? `type="SOLID" width="0.12 mm" color="#000000"` : `type="NONE" width="0.1 mm" color="#000000"`;
return ` <hh:borderFill id="${id}" threeD="0" shadow="0" centerLine="NONE" breakCellSeparateLine="0">
<hh:slash type="NONE" Crooked="0" isCounter="0"/>
<hh:backSlash type="NONE" Crooked="0" isCounter="0"/>
<hh:leftBorder ${line(sides)}/>
<hh:rightBorder ${line(sides)}/>
<hh:topBorder ${line(top)}/>
<hh:bottomBorder ${line(bottom)}/>
</hh:borderFill>`;
}
function charPr(id, pt, opts = {}) {
const all = (v) => LANGS.map(l => `${l.toLowerCase()}="${v}"`).join(" ");
return ` <hh:charPr id="${id}" height="${Math.round(pt * 100)}" textColor="#000000" shadeColor="none" useFontSpace="0" useKerning="0" symMark="NONE" borderFillIDRef="1">
<hh:fontRef ${all(0)}/>
<hh:ratio ${all(100)}/>
<hh:spacing ${all(0)}/>
<hh:relSz ${all(100)}/>
<hh:offset ${all(0)}/>${opts.bold ? "\n <hh:bold/>" : ""}${opts.underline ? `\n <hh:underline type="BOTTOM" shape="SOLID" color="#000000"/>` : ""}
</hh:charPr>`;
}
function paraPr(id, align, lineSpacing, hangPt = 0) {
const hang = Math.round(hangPt * 100);
return ` <hh:paraPr id="${id}" tabPrIDRef="0" condense="0" fontLineHeight="0" snapToGrid="0" suppressLineNumbers="0" checked="0" textDir="AUTO">
<hh:align horizontal="${align}" vertical="BASELINE"/>
<hh:heading type="NONE" idRef="0" level="0"/>
<hh:breakSetting breakLatinWord="KEEP_WORD" breakNonLatinWord="KEEP_WORD" widowOrphan="0" keepWithNext="0" keepLines="0" pageBreakBefore="0" lineWrap="BREAK"/>
<hh:autoSpacing eAsianEng="0" eAsianNum="0"/>
<hh:margin><hc:intent value="${-hang}" unit="HWPUNIT"/><hc:left value="${hang}" unit="HWPUNIT"/><hc:right value="0" unit="HWPUNIT"/><hc:prev value="0" unit="HWPUNIT"/><hc:next value="0" unit="HWPUNIT"/></hh:margin>
<hh:lineSpacing type="PERCENT" value="${lineSpacing}"/>
<hh:border borderFillIDRef="1" offsetLeft="0" offsetRight="0" offsetTop="0" offsetBottom="0" connect="0" ignoreMargin="0"/>
</hh:paraPr>`;
}
function headerXml() {
const chars = [charPr(CHAR.body, 14), charPr(CHAR.underline, 14, { underline: true }), charPr(CHAR.title, 16, { bold: true })];
const paras = [paraPr(PARA.title, "CENTER", 160), paraPr(PARA.center, "CENTER", 180),
...HANG_PT.map((pt, i) => paraPr(PARA.bodyLevel + i, "LEFT", 180, pt))];
const borders = [borderFill(BORDER.none, false, false, false), borderFill(BORDER.topBottom, true, true, true),
borderFill(BORDER.top, true, false, true), borderFill(BORDER.open, false, false, true), borderFill(BORDER.bottom, false, true, true)];
return `<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
<hh:head xmlns:hh="${NS_HEAD}" xmlns:hp="${NS_PARA}" xmlns:hc="${NS_CORE}" version="1.4" secCnt="1">
<hh:beginNum page="1" footnote="1" endnote="1" pic="1" tbl="1" equation="1"/>
<hh:refList>
${fontfaces()}
<hh:borderFills itemCnt="${borders.length}">
${borders.join("\n")}
</hh:borderFills>
<hh:charProperties itemCnt="${chars.length}">
${chars.join("\n")}
</hh:charProperties>
<hh:tabProperties itemCnt="0"/>
<hh:numberings itemCnt="1">
<hh:numbering id="1" start="0">
${[1, 2, 3, 4, 5, 6, 7].map(level => ` <hh:paraHead start="1" level="${level}" align="LEFT" useInstWidth="1" autoIndent="1" widthAdjust="0" textOffsetType="PERCENT" textOffset="50" numFormat="DIGIT" charPrIDRef="4294967295" checkable="0"/>`).join("\n")}
</hh:numbering>
</hh:numberings>
<hh:paraProperties itemCnt="${paras.length}">
${paras.join("\n")}
</hh:paraProperties>
<hh:styles itemCnt="1">
<hh:style id="0" type="PARA" name="바탕글" engName="Normal" paraPrIDRef="${PARA.bodyLevel}" charPrIDRef="${CHAR.body}" nextStyleIDRef="0" langID="1042" lockForm="0"/>
</hh:styles>
</hh:refList>
<hh:compatibleDocument targetProgram="HWP2018"><hh:layoutCompatibility/></hh:compatibleDocument>
</hh:head>`;
}
module.exports = { CONTAINER_XML, CONTENT_HPF, CHAR, PARA, HANG_PT, BORDER, headerXml };
scripts/amendment/hwpx.js
"use strict";
/**
* 신구조문대비표 → HWPX (한글 문서)
*
* A4 세로, 여백 위·아래 16㎜ · 좌우 25㎜ · 머리말·꼬리말 12.7㎜(법제업무편람).
* 2칸 표 — 머리행 "현 행 | 개 정 안" 은 쪽마다 반복, 가로줄은 조문 경계에만 긋는다.
*/
const { BORDER, CHAR, CONTAINER_XML, CONTENT_HPF, PARA, headerXml } = require("./hwpx-header.js");
const { createZip } = require("./zip.js");
const NS_SEC = "http://www.hancom.co.kr/hwpml/2011/section";
const NS_PARA = "http://www.hancom.co.kr/hwpml/2011/paragraph";
const MM = 7200 / 25.4;
const PAGE = { width: 59528, height: 84188, top: Math.round(16 * MM), bottom: Math.round(16 * MM), side: Math.round(25 * MM), header: Math.round(12.7 * MM) };
const TABLE_WIDTH = PAGE.width - 2 * PAGE.side;
const COL = Math.floor(TABLE_WIDTH / 2);
/** XML 이스케이프 + XML 1.0 에 못 쓰는 제어문자·짝 없는 서로게이트 제거(남으면 한글이 문서를 못 연다) */
const esc = (s) => s
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "")
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
/** 줄 첫머리로 내어쓰기 단계와 앞 공백(편람: 항 1칸 · 호 2칸 · 목 4칸 · 1) 6칸 · 가) 8칸)을 정한다 */
function levelOf(text) {
const t = text.replace(/^제\d+조(?:의\d+)?(?:\((?:[^()]|\([^()]*\))*\))?\s*/, "");
if (t !== text)
return { level: 0, pad: "" };
if (/^(?:[①-⑳㉑-㉟㊱-㊿]|<\d{1,2}>)/.test(t))
return { level: 1, pad: " " };
if (/^\d{1,3}(?:의\d{1,2})?\./.test(t))
return { level: 2, pad: " " };
if (/^[가-힣](?:의\d{1,2})?\./.test(t))
return { level: 3, pad: " " };
if (/^\d{1,3}\)/.test(t))
return { level: 4, pad: " " };
if (/^[가-힣]\)/.test(t))
return { level: 5, pad: " " };
return { level: 0, pad: "" };
}
function para(line) {
const plainText = line.map(s => s.text).join("");
const { level, pad } = levelOf(plainText);
const runs = line.filter(s => s.text).map(s => `<hp:run charPrIDRef="${s.u ? CHAR.underline : CHAR.body}"><hp:t>${esc(s.text)}</hp:t></hp:run>`);
if (pad)
runs.unshift(`<hp:run charPrIDRef="${CHAR.body}"><hp:t>${pad}</hp:t></hp:run>`);
return `<hp:p paraPrIDRef="${PARA.bodyLevel + level}" styleIDRef="0">${runs.join("") || `<hp:run charPrIDRef="${CHAR.body}"/>`}</hp:p>`;
}
function cell(col, row, border, paras, header) {
return `<hp:tc name="" header="${header ? 1 : 0}" hasMargin="0" protect="0" editable="1" dirty="0" borderFillIDRef="${border}">` +
`<hp:subList id="" textDirection="HORIZONTAL" lineWrap="BREAK" vertAlign="TOP" linkListIDRef="0" linkListNextIDRef="0" textWidth="0" textHeight="0" hasTextRef="0" hasNumRef="0">${paras}</hp:subList>` +
`<hp:cellAddr colAddr="${col}" rowAddr="${row}"/><hp:cellSpan colSpan="1" rowSpan="1"/>` +
`<hp:cellSz width="${COL}" height="1000"/><hp:cellMargin left="510" right="510" top="141" bottom="141"/></hp:tc>`;
}
function table(articles) {
const trs = [];
const headCell = (col, text) => cell(col, 0, BORDER.topBottom, `<hp:p paraPrIDRef="${PARA.center}" styleIDRef="0"><hp:run charPrIDRef="${CHAR.body}"><hp:t>${text}</hp:t></hp:run></hp:p>`, true);
trs.push(`<hp:tr>${headCell(0, "현 행")}${headCell(1, "개 정 안")}</hp:tr>`);
const flat = articles.flatMap(rows => rows.map((r, i) => ({ r, first: i === 0, last: i === rows.length - 1 })));
flat.forEach(({ r, first, last }, k) => {
const border = first && last ? BORDER.topBottom : first ? BORDER.top : last ? BORDER.bottom : BORDER.open;
trs.push(`<hp:tr>${cell(0, k + 1, border, r.old.map(para).join(""), false)}${cell(1, k + 1, border, r.new.map(para).join(""), false)}</hp:tr>`);
});
return `<hp:tbl id="1001" zOrder="0" numberingType="TABLE" textWrap="TOP_AND_BOTTOM" textFlow="BOTH_SIDES" lock="0" dropcapstyle="None" pageBreak="CELL" repeatHeader="1" rowCnt="${trs.length}" colCnt="2" cellSpacing="0" borderFillIDRef="${BORDER.none}" noShading="0">` +
`<hp:sz width="${COL * 2}" widthRelTo="ABSOLUTE" height="${trs.length * 1000}" heightRelTo="ABSOLUTE" protect="0"/>` +
`<hp:pos treatAsChar="0" affectLSpacing="0" flowWithText="1" allowOverlap="0" holdAnchorAndSO="0" vertRelTo="PARA" horzRelTo="COLUMN" vertAlign="TOP" horzAlign="LEFT" vertOffset="0" horzOffset="0"/>` +
`<hp:outMargin left="0" right="0" top="0" bottom="0"/><hp:inMargin left="510" right="510" top="141" bottom="141"/>${trs.join("")}</hp:tbl>`;
}
function secPr() {
return `<hp:secPr textDirection="HORIZONTAL" spaceColumns="1134" tabStop="8000" outlineShapeIDRef="1" memoShapeIDRef="0" textVerticalWidthHead="0" masterPageCnt="0">` +
`<hp:grid lineGrid="0" charGrid="0" wonggojiFormat="0"/><hp:startNum pageStartsOn="BOTH" page="0" pic="0" tbl="0" equation="0"/>` +
`<hp:visibility hideFirstHeader="0" hideFirstFooter="0" hideFirstMasterPage="0" border="SHOW_ALL" fill="SHOW_ALL" hideFirstPageNum="0" hideFirstEmptyLine="0" showLineNumber="0"/>` +
`<hp:pagePr landscape="WIDELY" width="${PAGE.width}" height="${PAGE.height}" gutterType="LEFT_ONLY"><hp:margin header="${PAGE.header}" footer="${PAGE.header}" gutter="0" left="${PAGE.side}" right="${PAGE.side}" top="${PAGE.top}" bottom="${PAGE.bottom}"/></hp:pagePr>` +
`<hp:footNotePr><hp:autoNumFormat type="DIGIT" userChar="" prefixChar="" suffixChar=")" supscript="0"/><hp:noteLine length="-1" type="SOLID" width="0.12 mm" color="#000000"/><hp:noteSpacing betweenNotes="283" belowLine="567" aboveLine="850"/><hp:numbering type="CONTINUOUS" newNum="1"/><hp:placement place="EACH_COLUMN" beneathText="0"/></hp:footNotePr>` +
`<hp:endNotePr><hp:autoNumFormat type="DIGIT" userChar="" prefixChar="" suffixChar=")" supscript="0"/><hp:noteLine length="14692344" type="SOLID" width="0.12 mm" color="#000000"/><hp:noteSpacing betweenNotes="0" belowLine="567" aboveLine="850"/><hp:numbering type="CONTINUOUS" newNum="1"/><hp:placement place="END_OF_DOCUMENT" beneathText="0"/></hp:endNotePr></hp:secPr>` +
`<hp:ctrl><hp:colPr id="" type="NEWSPAPER" layout="LEFT" colCount="1" sameSz="1" sameGap="0"/></hp:ctrl>`;
}
function sectionXml(titleLines, articles) {
const titles = titleLines.map((t, i) => `<hp:p paraPrIDRef="${PARA.title}" styleIDRef="0"><hp:run charPrIDRef="${CHAR.title}">${i === 0 ? secPr() : ""}<hp:t>${esc(t)}</hp:t></hp:run></hp:p>`);
const spacer = `<hp:p paraPrIDRef="${PARA.title}" styleIDRef="0"><hp:run charPrIDRef="${CHAR.body}"/></hp:p>`;
return `<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
<hs:sec xmlns:hs="${NS_SEC}" xmlns:hp="${NS_PARA}">
${titles.join("\n ")}
${spacer}
<hp:p paraPrIDRef="${PARA.center}" styleIDRef="0"><hp:run charPrIDRef="${CHAR.body}">${table(articles)}</hp:run></hp:p>
</hs:sec>`;
}
/** 제목 줄(예: ["건축법 일부개정법률안", "신・구조문대비표"]) + 조문별 행 → HWPX 바이트 */
function buildAmendmentHwpx(titleLines, articles) {
return createZip([
// OPC 규칙: mimetype 은 첫 항목이고 압축하지 않는다
{ name: "mimetype", data: "application/hwp+zip", store: true },
{ name: "META-INF/container.xml", data: CONTAINER_XML },
{ name: "Contents/content.hpf", data: CONTENT_HPF },
{ name: "Contents/header.xml", data: headerXml() },
{ name: "Contents/section0.xml", data: sectionXml(titleLines, articles) },
{ name: "Preview/PrvText.txt", data: titleLines.join("\n") },
]);
}
module.exports = { buildAmendmentHwpx };
scripts/amendment/law-json.js
"use strict";
/**
* 법제처 조문 JSON(DRF `조문단위`) → 조문 텍스트 변환 유틸 + 조문 번호 → JO 코드.
* 조문내용(조 머리) 뒤에 항·호·목 줄을 이어 붙이면 대비표 파서가 읽는 모양이 된다.
*/
const normalizeSeparators = (raw) => raw
.replace(/[‐‑‒–—―﹘﹣-]/gu, "-")
.replace(/[·•]/gu, " ");
const stripClauseAndItem = (raw) => raw
.replace(/제?\d+항.*$/u, "")
.replace(/제?\d+호.*$/u, "")
.replace(/제?\d+목.*$/u, "");
/**
* 조문 표기 → 법령용 6자리 JO 코드(AAAABB: 조 4자리 + 가지번호 2자리)
* "제38조" → "003800", "10조의2" → "001002"
*/
function buildJO(input) {
const sanitized = stripClauseAndItem(normalizeSeparators(input)
.replace(/제|第/gu, "")
.replace(/조문|條/gu, "조")
.replace(/之/gu, "의")
.replace(/[()]/gu, "")
.replace(/\s+/gu, "")
.trim());
const match = sanitized.match(/(\d+)(?:조)?(?:(?:의|-)\s*(\d+))?/u);
if (!match) throw new Error(`조문 패턴을 인식할 수 없습니다: ${input}`);
const articleNumber = Number.parseInt(match[1], 10);
const branchNumber = match[2] ? Number.parseInt(match[2], 10) : 0;
return `${String(articleNumber).padStart(4, "0")}${String(branchNumber).padStart(2, "0")}`;
}
/** 중첩 배열 평탄화 후 문자열 결합 (<img> 태그 제외) */
function flattenContent(value) {
if (typeof value === "string")
return value;
if (!Array.isArray(value))
return "";
const result = [];
for (const item of value) {
if (typeof item === "string") {
if (!item.startsWith("<img") && !item.startsWith("</img")) {
result.push(item);
}
}
else if (Array.isArray(item)) {
result.push(flattenContent(item));
}
}
return result.join("\n");
}
/** 목 배열을 소속 호별로 그룹핑.
* 법제처 JSON은 목(目)을 호의 자식이 아니라 **항 레벨 형제 배열**로 주고 목 객체에
* 소속 호 정보가 없다(키는 목번호·목내용뿐). 목번호가 '가'로 리셋되는 지점을 새 호의
* 목 시작으로 보고 순서를 복원한다 — 조판 규칙상 각 호의 목은 항상 '가'부터 시작한다. */
function groupMokByReset(mokArray) {
const groups = [];
for (const mok of mokArray) {
if (!mok || typeof mok !== "object")
continue;
const num = String(mok.목번호 ?? "").trim();
if (num.startsWith("가") || groups.length === 0)
groups.push([]);
groups[groups.length - 1].push(mok);
}
return groups;
}
/** 목 배열 → 텍스트 (앞에 개행 포함) */
function renderMok(mokArray) {
let out = "";
for (const mok of mokArray) {
if (!mok || typeof mok !== "object")
continue;
if (mok.목내용) {
const mokContent = flattenContent(mok.목내용);
if (mokContent)
out += "\n" + mokContent;
}
}
return out;
}
/** 항 배열에서 내용 추출 (재귀적으로 호/목 처리) */
function extractHangContent(hangInput) {
// API가 단일 항을 객체로 반환하는 경우 배열로 정규화
const hangArray = Array.isArray(hangInput) ? hangInput : [hangInput];
let content = "";
for (const hang of hangArray) {
if (!hang || typeof hang !== "object")
continue;
if (hang.항내용) {
const hangContent = flattenContent(hang.항내용);
if (hangContent) {
content += (content ? "\n" : "") + hangContent;
}
}
// 호도 단일 객체일 수 있으므로 정규화
const hoArray = hang.호 ? (Array.isArray(hang.호) ? hang.호 : [hang.호]) : [];
// 항 레벨 목 — 그룹 수가 호 수와 같을 때만 각 호 뒤에 배치(원문 순서 복원)
const hangMokArray = hang.목 ? (Array.isArray(hang.목) ? hang.목 : [hang.목]) : [];
const mokGroups = groupMokByReset(hangMokArray);
const alignable = hoArray.length > 0 && mokGroups.length === hoArray.length;
for (let i = 0; i < hoArray.length; i++) {
const ho = hoArray[i];
if (!ho || typeof ho !== "object")
continue;
if (ho.호내용) {
const hoContent = flattenContent(ho.호내용);
if (hoContent) {
content += "\n" + hoContent;
}
}
// 호 안에 목이 실려 오는 응답도 있어 기존 경로는 유지
const mokArray = ho.목 ? (Array.isArray(ho.목) ? ho.목 : [ho.목]) : [];
content += renderMok(mokArray);
if (alignable)
content += renderMok(mokGroups[i]);
}
// 호에 배정하지 못한 항 레벨 목 — 누락시키지 않고 항 말미에 모아 출력
if (!alignable && hangMokArray.length > 0) {
const rest = renderMok(hangMokArray);
if (rest) {
content += "\n[참고] 아래 목은 위 각 호의 세부 항목이나 API 응답 구조상 소속 호를 특정할 수 없어 일괄 표시합니다.";
content += rest;
}
}
}
return content;
}
/**
* HTML 정리 - 엔티티 디코딩 순서 중요: & 최후 처리 (이중 인코딩 방지)
*
* 태그 제거는 영문 태그와 주석으로 한정한다. 조문 본문에는 `<개정 2012.8.1.>`·`<신설 2005.12.29>`·
* `부칙 <1999.01.11>` 같은 꺾쇠 표기가 원문 그대로 오는데, `<[^>]+>` 로 지우면 개정 시점이 통째로
* 사라졌다(민법 전문에서만 149개, 2026-09-23). 행위시법 판단의 근거라 남긴다. admin-rule 의
* markChangedParts 와 같은 규칙이다.
*/
function cleanHtml(text) {
return text
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/<\/?[A-Za-z][^>]*>/g, '')
.replace(/ /g, ' ')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/·/g, '·')
.replace(/‘/g, '‘')
.replace(/’/g, '’')
.replace(/&/g, '&') // & 반드시 마지막 (이중 인코딩 &lt; → < 방지)
.trim();
}
module.exports = { buildJO, cleanHtml, extractHangContent, flattenContent };
scripts/amendment/LICENSE-korean-law-mcp
MIT License
Copyright (c) 2025 Chris
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
scripts/amendment/marks.js
"use strict";
const { splitEdges } = require("./units.js");
const OMIT_OLD = "(생 략)";
const OMIT_NEW = "(현행과 같음)";
const MARK_NEW = "<신 설>";
const MARK_DEL = "<삭 제>";
const plain = (text) => ({ text, u: false });
const under = (text) => ({ text, u: true });
/** 개정안 칸의 같은 부분 → 줄표. 문장 끝 마침표와 양 끝 공백(밑줄과의 경계)은 남긴다.
* before 는 이 조각 앞 글자 — 밑줄 친 "…한다" 뒤에 마침표만 남은 조각도 문장 끝으로 본다(#180) */
function dashify(text, before = "") {
const [lead, core, tail] = splitEdges(text);
const prev = lead ? lead[lead.length - 1] : before;
let out = "";
for (let k = 0; k < core.length; k++) {
const sentenceEnd = core[k] === "." && (k === core.length - 1 || /\s/.test(core[k + 1])) && /[가-힣)」\]”]/.test(k > 0 ? core[k - 1] : prev);
if (sentenceEnd)
out += ".";
else
out += out.endsWith(".") && /\s/.test(core[k]) ? " " : "-";
}
return lead + out + tail;
}
/** 개정안 칸 세그먼트 — 밑줄 아닌 부분을 줄표로 */
function dashSegs(segs) {
return segs.map((s, i) => s.u ? s : plain(dashify(s.text, segs[i - 1]?.text.slice(-1) ?? "")));
}
function joinLabels(labels) {
if (labels.length <= 1)
return labels[0] ?? "";
if (labels.length === 2)
return `${labels[0]}·${labels[1]}`;
return `${labels[0]} ∼ ${labels[labels.length - 1]}`;
}
module.exports = { OMIT_OLD, OMIT_NEW, MARK_NEW, MARK_DEL, plain, under, dashify, dashSegs, joinLabels };
scripts/amendment/render.js
"use strict";
/**
* 신구조문대비표 — 조문 한 개(현행·개정안 트리) → 대비표 행
*
* 행 하나 = 현행 칸·개정안 칸이 마주 보는 한 묶음(법제처 신구법비교의 조문 쌍과 같은 단위).
* - 같은 단위가 이어지면 "①·② (생 략)" / "(현행과 같음)" 한 줄로 묶는다.
* - 조 머리(제N조(제목))는 첫 줄 앞에 붙고, 제목이 바뀌면 "(제목)" 을 통째 밑줄.
* - 바뀐 단위: 현행 칸은 바뀐 부분 밑줄, 개정안 칸은 같은 부분 "----" + 바뀐 부분 밑줄.
*/
const { diffInline } = require("./diff.js");
const { alignUnits, isDeletedInPlace, isSame } = require("./align.js");
const { fillSame, normalizeForCompare, unitLines } = require("./units.js");
const { dashify, dashSegs, joinLabels, plain, under, MARK_DEL, MARK_NEW, OMIT_NEW, OMIT_OLD } = require("./marks.js");
const TAIL = /\s(다만,|이 경우)\s/;
/** 끝에 붙은 단서("다만,")·후단("이 경우") 문장을 떼어 낸다 */
function splitTail(body) {
const m = body.match(new RegExp(`^([\\s\\S]*?\\.)${TAIL.source}([\\s\\S]*)$`));
if (!m)
return { main: body, tail: "", kind: null };
return { main: m[1], tail: `${m[2]} ${m[3]}`, kind: m[2] === "다만," ? "단서" : "후단" };
}
const allUnder = (s) => s.every(x => x.u || !x.text.trim());
/** 본문 비교 → [현행 세그먼트, 개정안 세그먼트(줄표 처리됨), 통째 교체 여부] */
function diffBody(oldBody, newBody) {
if (normalizeForCompare(oldBody) === normalizeForCompare(newBody)) {
return { old: [plain(oldBody)], new: [plain(dashify(newBody))], whole: false };
}
// 단서·후단 표지는 앞 문장이 이어질 때만 — 통째로 바뀐 항의 "이 경우"는 후단 신설이 아니다
const o = splitTail(oldBody), n = splitTail(newBody);
if (!o.kind && n.kind) {
const d = diffInline(oldBody, n.main);
if (!allUnder(d.old) || !allUnder(d.new)) {
return { old: [...d.old, plain(" "), under(`<${n.kind} 신설>`)], new: [...dashSegs(d.new), plain(" "), under(n.tail)], whole: false };
}
}
if (o.kind && !n.kind) {
const d = diffInline(o.main, newBody);
if (!allUnder(d.old) || !allUnder(d.new)) {
return { old: [...d.old, plain(" "), under(o.tail)], new: [...dashSegs(d.new), plain(" "), under(`<${o.kind} 삭제>`)], whole: false };
}
}
const d = diffInline(oldBody, newBody);
return { old: d.old, new: dashSegs(d.new), whole: allUnder(d.old) && allUnder(d.new) };
}
const withLabel = (unit, segs, u) => {
const gap = unit.gap ? [u && segs[0]?.u ? under(unit.gap) : plain(unit.gap)] : [];
return [u ? under(unit.label) : plain(unit.label), ...gap, ...segs];
};
const underAll = (lines) => lines.map(l => [under(l)]);
function changedRows(n, rows) {
const o = n.old, w = n.new;
// 하위 호·목이 새로 생기거나 다 없어지면 그 단위를 통째로 바꾼 것으로, 하위까지 한 칸에 적는다
// (선원법 제179조·국민기초생활 보장법 제67조 — 공통 문구가 있어도 전부 밑줄)
const d = diffBody(o.body, w.body);
if (d.whole && (o.children.length === 0) !== (w.children.length === 0)) {
rows.push({ old: underAll(unitLines(o)), new: underAll(unitLines(w)) });
return;
}
const labelU = d.whole || o.label !== w.label;
rows.push({ old: [withLabel(o, d.old, labelU)], new: [withLabel(w, d.new, labelU)] });
renderNodes(n.children, rows);
}
function renderNodes(nodes, rows) {
for (let k = 0; k < nodes.length;) {
const n = nodes[k];
if (isSame(n)) {
const run = [];
while (k < nodes.length && isSame(nodes[k]))
run.push(nodes[k++]);
const labels = joinLabels(run.map(r => r.old.label));
rows.push({ old: [[plain(`${labels} ${OMIT_OLD}`)]], new: [[plain(`${labels} ${OMIT_NEW}`)]] });
continue;
}
k++;
if (!n.old)
rows.push({ old: [[under(MARK_NEW)]], new: underAll(unitLines(n.new)) });
else if (!n.new || isDeletedInPlace(n))
rows.push({ old: underAll(unitLines(n.old)), new: [[under(MARK_DEL)]] });
else
changedRows(n, rows);
}
}
function headingSegs(t, titleChanged) {
return [plain(t.num), ...(t.title ? [titleChanged ? under(t.title) : plain(t.title)] : [])];
}
/** 조 머리를 첫 행 첫 줄 앞에 붙인다 */
function prefixHeading(rows, oldHead, newHead) {
const first = rows[0];
first.old[0] = [...oldHead, plain(" "), ...first.old[0]];
first.new[0] = [...newHead, plain(" "), ...first.new[0]];
}
/** 조 전체 줄 — 첫 단위는 조 머리와 한 줄("제5조(제목) ① …") */
function articleLines(t) {
const head = `${t.num}${t.title}`;
const body = [...(t.lead ? [t.lead] : []), ...t.children.flatMap((u) => unitLines(u))];
return body.length ? [`${head} ${body[0]}`, ...body.slice(1)] : [head];
}
const isArticleDeleted = (t) => /^삭제\.?$/.test(t.lead) && t.children.length === 0;
/**
* 조의 짜임 — 본문만 있던 조가 항으로 나뉘면(또는 반대) 조 전체를 통째로 바꾼 것으로 적는다
* (저작권법 제106조의2). 본문 아래 호가 붙는 것은 짜임 변경이 아니다(수산업법 제32조: 호는 <신 설>).
*/
const shapeOf = (t) => t.lead ? "lead" : `units:${t.children[0]?.kind ?? ""}`;
/** 개정안에 아예 없는(삭제 표기도 없는) 현행 단위 — 바뀐 항만 적어 나머지가 지워지는 실수를 알린다 */
function droppedLabels(nodes, prefix = "") {
return nodes.flatMap(n => !n.new ? [`${prefix}${n.old.label}`] : n.old ? droppedLabels(n.children, `${prefix}${n.old.label} `) : []);
}
/**
* 조문 한 개의 대비표 행. 현행이 없으면 신설, 개정안이 "제N조 삭제" 면 삭제.
* 바뀐 곳이 없으면 null.
*/
function renderArticle(oldTree, proposal, notes = []) {
if (!oldTree)
return [{ old: [[under(MARK_NEW)]], new: underAll(articleLines(proposal)) }];
const missing = [];
const newTree = fillSame(proposal, oldTree, missing);
if (missing.length)
notes.push(`"(현행과 같음)"으로 적은 ${missing.join(", ")}이(가) 현행에 없어 뺐습니다`);
if (isArticleDeleted(newTree) && !isArticleDeleted(oldTree)) {
return [{ old: underAll(articleLines(oldTree)), new: [[under(`${newTree.num} 삭제`)]] }];
}
const titleChanged = normalizeForCompare(oldTree.title) !== normalizeForCompare(newTree.title);
const oldHead = headingSegs(oldTree, titleChanged), newHead = headingSegs(newTree, titleChanged);
if (shapeOf(oldTree) !== shapeOf(newTree)) {
return [{ old: underAll(articleLines(oldTree)), new: underAll(articleLines(newTree)) }];
}
const nodes = alignUnits(oldTree.children, newTree.children);
const dropped = droppedLabels(nodes);
if (dropped.length) {
notes.push(`개정안에 없는 ${dropped.join(", ")}을(를) <삭 제>로 적었습니다 — 그대로 두는 것이면 개정안에 "② ∼ ⑤ (현행과 같음)"처럼 적거나 조문 전체를 넣으세요`);
}
const leadSame = normalizeForCompare(oldTree.lead) === normalizeForCompare(newTree.lead);
const childrenSame = nodes.every(isSame);
if (leadSame && childrenSame && !titleChanged)
return null;
if (leadSame && childrenSame && nodes.length === 0) {
return [{ old: [[...oldHead, plain(` ${OMIT_OLD}`)]], new: [[...newHead, plain(` ${OMIT_NEW}`)]] }];
}
const rows = [];
if (oldTree.lead || newTree.lead) {
const d = diffBody(oldTree.lead, newTree.lead);
rows.push({ old: [d.old], new: [d.new] });
}
renderNodes(nodes, rows);
prefixHeading(rows, oldHead, newHead);
return rows;
}
module.exports = { renderArticle };
scripts/amendment/table.js
"use strict";
/**
* 신・구조문대비표 작성 (일부개정 법령안)
*
* 개정안 조문 전문만 받으면 현행 조문은 법제처에서 가져와 대조한다. 바뀐 부분 밑줄, 개정안 칸의 같은
* 부분 "----", 바뀌지 않은 항·호 "(생 략)"/"(현행과 같음)", <신 설>·<삭 제>. 조례·행정규칙·미공포안처럼
* 자동 조회가 안 되는 현행은 currentText 로 받는다.
*/
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const { AmendmentError } = require("./errors.js");
const { buildJO } = require("./law-json.js");
const { parseArticle } = require("./units.js");
const { renderArticle } = require("./render.js");
const { rowsToMarkdown } = require("./format.js");
const { buildAmendmentHwpx } = require("./hwpx.js");
const { fetchCurrentArticle, resolveLaw } = require("./current.js");
const MAX_AMENDED_ARTICLES = 30;
/** 제명 끝으로 개정안 이름: 법률 → 일부개정법률안, 영·부령 → 일부개정령안, 조례 → 일부개정조례안 */
function draftTitle(lawName) {
if (!lawName) return "일부개정안";
if (/조례$/.test(lawName)) return `${lawName} 일부개정조례안`;
if (/(령|시행규칙)$/.test(lawName)) return `${lawName} 일부개정령안`;
if (/법(률)?$/.test(lawName)) return `${lawName} 일부개정법률안`;
return `${lawName} 일부개정안`;
}
const isNonEmptyString = (v) => typeof v === "string" && v.trim() !== "";
/** 입력 검증 — 법제처를 부르기 전에 막는다 */
function validateInput(input) {
const bad = (message) => new AmendmentError(message, "INVALID_PARAM");
if (!input || typeof input !== "object") throw bad("입력은 JSON 객체여야 합니다.");
const { amendments } = input;
if (!Array.isArray(amendments) || amendments.length < 1) throw bad("amendments 에 개정하는 조문을 1개 이상 넣으세요.");
if (amendments.length > MAX_AMENDED_ARTICLES) throw bad(`한 번에 ${MAX_AMENDED_ARTICLES}개 조문까지만 만들 수 있습니다 (입력 ${amendments.length}개).`);
amendments.forEach((a, i) => {
if (!a || !isNonEmptyString(a.text)) throw bad(`amendments[${i}].text 에 개정안 조문 전문을 넣으세요.`);
if (a.currentText !== undefined && typeof a.currentText !== "string") throw bad(`amendments[${i}].currentText 는 문자열이어야 합니다.`);
});
const hasLaw = [input.lawName, input.mst, input.lawId].some(isNonEmptyString);
if (!hasLaw && !amendments.every((a) => a.currentText !== undefined)) {
throw bad("lawName·mst·lawId 중 하나를 주거나, 모든 조문에 currentText(현행 원문)를 넣으세요.");
}
if (input.outputPath !== undefined) resolveOutputPath(input.outputPath);
}
/** "~/Desktop/…" 은 셸이 아니면 풀리지 않는다 — 홈 디렉터리로 푼다 */
function resolveOutputPath(outputPath) {
if (!isNonEmptyString(outputPath)) throw new AmendmentError("outputPath 는 비어 있지 않은 경로여야 합니다.", "INVALID_PARAM");
const target = path.resolve(outputPath.replace(/^~(?=$|[\\/])/, os.homedir()));
if (path.extname(target).toLowerCase() !== ".hwpx") {
throw new AmendmentError("outputPath 는 .hwpx 로 끝나야 합니다.", "INVALID_PARAM");
}
return target;
}
async function buildArticle(client, law, a) {
const newTree = parseArticle(a.text);
const jo = (a.jo?.trim() || newTree.num).replace(/\s/g, "");
if (!jo) throw new AmendmentError("조 번호를 알 수 없습니다 — jo 를 주거나 text 를 '제N조(제목)'으로 시작하세요.", "INVALID_PARAM");
let joCode;
try {
joCode = buildJO(jo);
} catch (error) {
throw new AmendmentError(error.message, "INVALID_PARAM");
}
const current = a.currentText !== undefined ? a.currentText : await fetchCurrentArticle(client, law, jo);
const oldTree = current?.trim() ? parseArticle(current) : null;
const notes = [];
if (newTree.num && buildJO(newTree.num) !== joCode) {
notes.push(`${jo}: jo(${jo})와 개정안 머리(${newTree.num})가 다릅니다 — 현행은 ${jo}로 조회했습니다`);
}
if (!newTree.num) {
newTree.num = oldTree?.num || jo;
newTree.title = oldTree?.title ?? "";
if (oldTree?.title) notes.push(`${jo}: 개정안에 조 제목이 없어 현행 제목 ${oldTree.title}을 그대로 썼습니다`);
}
if (!oldTree && current === null) notes.push(`${jo}: 현행에 없는 조라 신설로 처리했습니다 — 번호가 맞는지 확인하세요`);
const found = [];
const rows = renderArticle(oldTree, newTree, found);
notes.push(...found.map((n) => `${jo}: ${n}`));
if (!rows) notes.push(`${jo}: 현행과 같아 표에서 뺐습니다`);
return { joCode, rows, notes };
}
async function saveHwpx(outputPath, title, articles) {
const target = resolveOutputPath(outputPath);
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(target, buildAmendmentHwpx([title, "신・구조문대비표"], articles));
return target;
}
/**
* @param {{ searchLaws(query: string): Promise<object[]>, getLawJson(p: object): Promise<object> }} client
* @param {{ lawName?: string, mst?: string, lawId?: string, title?: string, outputPath?: string,
* amendments: { jo?: string, text: string, currentText?: string }[] }} input
* @returns {Promise<{ text: string, isError: boolean }>}
*/
async function amendmentTable(client, input) {
try {
validateInput(input);
const needsFetch = input.amendments.some((a) => a.currentText === undefined);
const law = needsFetch ? await resolveLaw(client, input) : null;
const results = await Promise.all(input.amendments.map((a) => buildArticle(client, law, a)));
const seen = new Set();
for (const r of results) {
if (seen.has(r.joCode)) throw new AmendmentError("같은 조를 두 번 넣었습니다 — 한 조는 개정안 하나로 합치세요.", "INVALID_PARAM");
seen.add(r.joCode);
}
results.sort((x, y) => x.joCode.localeCompare(y.joCode));
const lawName = law?.lawName || input.lawName || "";
const title = input.title?.trim() || draftTitle(lawName);
const articles = results.map((r) => r.rows).filter((r) => r !== null);
const lines = [];
if (articles.length) {
lines.push(rowsToMarkdown(`${title} 신・구조문대비표`, articles.flat()), "");
lines.push("굵은 글씨는 개정되는 부분(한글 문서에서는 밑줄), '----'는 현행과 같은 부분입니다.");
} else {
lines.push("[NO_CHANGE] 입력한 개정안이 모두 현행과 같습니다.");
}
if (law) lines.push(`현행 원문: 국가법령정보센터 「${law.lawName}」${law.mst ? ` (MST ${law.mst})` : ""} 현행 조문을 조회해 대조했습니다.`);
const notes = results.flatMap((r) => r.notes);
if (notes.length) lines.push("", "확인할 점:", ...notes.map((n) => `- ${n}`));
if (input.outputPath && articles.length) {
const saved = await saveHwpx(input.outputPath, title, articles);
lines.push("", `HWPX 저장: ${saved} (한양신명조 14pt · 줄 간격 180% · 법제업무편람 서식)`);
}
return { text: lines.join("\n"), isError: false };
} catch (error) {
if (!(error instanceof AmendmentError)) throw error;
return { text: [`[${error.code}] ${error.message}`, ...error.hints.map((h) => `- ${h}`)].join("\n"), isError: true };
}
}
module.exports = { MAX_AMENDED_ARTICLES, amendmentTable, draftTitle, validateInput };
scripts/amendment/units.js
"use strict";
/**
* 신구조문대비표 — 조문 텍스트 → 단위 트리 (조 · 항 · 호 · 목 · 세목)
*
* 현행(법제처 본문)과 개정안(사용자 입력)을 같은 파서로 읽어야 대조가 맞는다.
* 한 줄 = 한 단위가 원칙이고, 조 머리(제N조(제목)) 뒤에 같은 줄로 붙은 ①·1. 은 떼어 낸다.
* 라벨 없는 줄은 앞 단위 본문에 잇는다(줄바꿈된 긴 항).
* 개정안에는 대비표 관행대로 "② ∼ ⑤ (현행과 같음)" 같은 자리표시를 쓸 수 있다 — fillSame 이 현행에서 채운다.
*/
const LEVEL = { hang: 1, ho: 2, mok: 3, sub1: 4, sub2: 5 };
const MOK_CHARS = "가나다라마바사아자차카타파하거너더러머버서어저처커터퍼허";
const LABEL_PATTERNS = [
// 법제처 본문은 ⑮ 다음 항을 "<16>"·"<17>"로 적는다
["hang", /^([①-⑳㉑-㉟㊱-㊿]|<\d{1,2}>)(\s*)/],
["ho", /^(\d{1,3}(?:의\d{1,2})?\.)(?=\s|$)(\s*)/],
["mok", new RegExp(`^([${MOK_CHARS}](?:의\\d{1,2})?\\.)(?=\\s|$)(\\s*)`)],
["sub1", /^(\d{1,3}\))(\s+)/],
["sub2", new RegExp(`^([${MOK_CHARS}]\\))(\\s+)`)],
];
const LABEL_SRC = `[①-⑳㉑-㉟㊱-㊿]|<\\d{1,2}>|\\d{1,3}(?:의\\d{1,2})?\\.|[${MOK_CHARS}](?:의\\d{1,2})?\\.|\\d{1,3}\\)|[${MOK_CHARS}]\\)`;
const SAME_NOTE = "\\((?:현행과\\s*같음|생\\s*략)\\)";
const PLACEHOLDER = new RegExp(`^(${LABEL_SRC})(?:\\s*[·ㆍ∼~~]\\s*(${LABEL_SRC}))?\\s*${SAME_NOTE}$`);
const SAME_LEAD = new RegExp(`^${SAME_NOTE}$`);
// "제3조에 따른 …" 같은 본문 첫머리는 조 머리가 아니다 — 번호 뒤는 공백·괄호·끝만
const HEADING = /^(제\d{1,4}조(?:의\d{1,3})?)(?=[\s(]|$)\s*(\((?:[^()]|\([^()]*\))*\))?\s*/;
/**
* 연혁 표기 제거 — 대비표에는 `<개정 2013.3.23>`·`[전문개정 2008.3.21]`·`삭제<2009.6.9>` 의
* 날짜 꼬리가 실리지 않는다(법제처 신구법비교 원문 2,284쌍 실측).
*/
function stripHistoryNotes(text) {
return text
.split(/\r?\n/)
.filter(line => !/^\s*\[[^\]]*\]\s*$/.test(line))
.map(line => line
.replace(/<(?:개정|신설|전문개정|본조신설|제목개정|타법개정|종전)[^<>]{0,300}>/g, "")
.replace(/<\d{4}\.\s?\d{1,2}\.\s?\d{1,2}\.?(?:,\s?\d{4}\.\s?\d{1,2}\.\s?\d{1,2}\.?)*>/g, "")
.replace(/\[(?:전문개정|본조신설|제목개정|본조제목개정|종전|시행일)[^\]]{0,300}\]/g, "")
// 줄 안 공백은 종류 불문 하나로(전각 공백·\r 연속이 뒤 정규식을 느리게 하지 않게)
.replace(/\s+/g, " ")
.trim())
.filter(Boolean)
.map(curlyQuotes)
.join("\n");
}
/**
* 법제처 본문 API 는 곧은 겹따옴표(")를 주지만 법령 원문·대비표는 “ ”다 — 줄 안에서 번갈아 바꾼다.
* 짝이 안 맞는 줄은 건드리지 않는다. 홑따옴표는 대비표도 ' 그대로라 두지 않는다.
*/
function curlyQuotes(line) {
if ((line.split('"').length - 1) % 2)
return line;
let k = 0;
return line.replace(/"/g, () => (k++ % 2 ? "”" : "“"));
}
function matchLabel(line) {
for (const [kind, re] of LABEL_PATTERNS) {
const m = line.match(re);
if (m)
return { kind, label: m[1], gap: m[2] ? " " : "", rest: line.slice(m[0].length) };
}
return null;
}
/** 조문 텍스트 한 개를 트리로. 첫 줄이 조 머리가 아니면 num·title 은 빈 값 */
function parseArticle(text) {
const lines = stripHistoryNotes(text).split("\n");
const tree = { num: "", title: "", lead: "", children: [] };
const first = lines[0] ?? "";
const h = first.match(HEADING);
if (h) {
tree.num = h[1];
tree.title = h[2] ?? "";
lines[0] = first.slice(h[0].length);
if (!lines[0])
lines.shift();
}
const stack = [];
let last = null;
for (const line of lines) {
const ph = line.match(PLACEHOLDER);
const lab = ph ? { ...matchLabel(`${ph[1]} `), rest: "" } : matchLabel(line);
if (!lab) {
if (last)
last.body = last.body ? `${last.body} ${line}` : line;
else
tree.lead = tree.lead ? `${tree.lead} ${line}` : line;
continue;
}
const unit = { kind: lab.kind, label: lab.label, gap: lab.gap, body: lab.rest.trim(), children: [] };
if (ph)
unit.sameUntil = ph[2] ?? ph[1];
while (stack.length && LEVEL[stack[stack.length - 1].kind] >= LEVEL[unit.kind])
stack.pop();
const parent = stack[stack.length - 1];
if (parent)
parent.children.push(unit);
else
tree.children.push(unit);
stack.push(unit);
last = unit;
}
return tree;
}
/**
* 개정안의 자리표시를 현행 단위로 채운다. 현행에 없는 번호를 가리키면 빼고 missing 에 적는다.
* 조 본문 자체가 "(현행과 같음)"이면 현행 본문을 쓴다.
*/
function fillSame(proposal, current, missing) {
const lead = SAME_LEAD.test(proposal.lead) ? current.lead : proposal.lead;
return { ...proposal, lead, children: fillUnits(proposal.children, current.children, missing) };
}
function fillUnits(units, current, missing) {
const out = [];
for (const u of units) {
const at = (label) => current.findIndex(c => c.kind === u.kind && c.label === label);
if (u.sameUntil !== undefined) {
const i = at(u.label), j = at(u.sameUntil);
if (i < 0 || j < i)
missing.push(u.sameUntil === u.label ? u.label : `${u.label} ∼ ${u.sameUntil}`);
else
out.push(...structuredClone(current.slice(i, j + 1)));
continue;
}
out.push({ ...u, children: fillUnits(u.children, current[at(u.label)]?.children ?? [], missing) });
}
return out;
}
/** 단위를 라벨까지 붙인 원문 줄들로 (자손 포함) */
function unitLines(unit) {
const head = unit.body ? `${unit.label}${unit.gap}${unit.body}` : unit.label;
return [head, ...unit.children.flatMap(unitLines)];
}
/** 앞뒤 공백과 가운데를 정규식 없이 가른다(선형) */
function splitEdges(s) {
const lead = s.length - s.trimStart().length;
if (lead === s.length)
return [s, "", ""];
const tail = s.length - s.trimEnd().length;
return [s.slice(0, lead), s.slice(lead, s.length - tail), s.slice(s.length - tail)];
}
/** 대비 판정용 정규형 — 공백 흔들림과 가운뎃점 변종만 맞춘다 */
function normalizeForCompare(s) {
return s.replace(/\s+/g, " ").replace(/[·‧•・]/g, "ㆍ").replace(/[“”]/g, '"').trim();
}
module.exports = { stripHistoryNotes, parseArticle, fillSame, unitLines, splitEdges, normalizeForCompare };
scripts/amendment/zip.js
"use strict";
/**
* 최소 ZIP 작성기 — HWPX(OPC) 패키지용. 외부 의존성 없이 node:zlib 만 쓴다.
* 항목은 넣은 순서대로 쓰고, store 항목은 압축하지 않는다(mimetype 규칙).
*/
const zlib = require("node:zlib");
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n += 1) {
let c = n;
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
t[n] = c >>> 0;
}
return t;
})();
function crc32(buf) {
let c = 0xffffffff;
for (let i = 0; i < buf.length; i += 1) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
// 파일 이름·내용은 UTF-8, 시각은 고정(재현 가능한 출력): 1980-01-01 00:00
const DOS_TIME = 0;
const DOS_DATE = (0 << 9) | (1 << 5) | 1;
const UTF8_FLAG = 0x0800;
/** @param {{name: string, data: string | Buffer, store?: boolean}[]} entries */
function createZip(entries) {
const locals = [];
const centrals = [];
let offset = 0;
for (const entry of entries) {
const name = Buffer.from(entry.name, "utf8");
const data = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data, "utf8");
const deflated = entry.store || data.length === 0 ? null : zlib.deflateRawSync(data, { level: 9 });
const useDeflate = deflated !== null && deflated.length < data.length;
const body = useDeflate ? deflated : data;
const method = useDeflate ? 8 : 0;
const crc = crc32(data);
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(UTF8_FLAG, 6);
local.writeUInt16LE(method, 8);
local.writeUInt16LE(DOS_TIME, 10);
local.writeUInt16LE(DOS_DATE, 12);
local.writeUInt32LE(crc, 14);
local.writeUInt32LE(body.length, 18);
local.writeUInt32LE(data.length, 22);
local.writeUInt16LE(name.length, 26);
locals.push(local, name, body);
const central = Buffer.alloc(46);
central.writeUInt32LE(0x02014b50, 0);
central.writeUInt16LE(20, 4);
central.writeUInt16LE(20, 6);
central.writeUInt16LE(UTF8_FLAG, 8);
central.writeUInt16LE(method, 10);
central.writeUInt16LE(DOS_TIME, 12);
central.writeUInt16LE(DOS_DATE, 14);
central.writeUInt32LE(crc, 16);
central.writeUInt32LE(body.length, 20);
central.writeUInt32LE(data.length, 24);
central.writeUInt16LE(name.length, 28);
central.writeUInt32LE(offset, 42);
centrals.push(central, name);
offset += local.length + name.length + body.length;
}
const centralSize = centrals.reduce((n, b) => n + b.length, 0);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(entries.length, 8);
end.writeUInt16LE(entries.length, 10);
end.writeUInt32LE(centralSize, 12);
end.writeUInt32LE(offset, 16);
return Buffer.concat([...locals, ...centrals, end]);
}
module.exports = { createZip, crc32 };
skill.json
{
"name": "korean-law-search",
"description": "Search Korean statutes, articles, precedents, interpretations, and local ordinances via k-skill-proxy, and draft 신・구조문대비표 (old/new article comparison tables, optional HWPX). Use when the user asks for Korean law/article/precedent lookups or an amendment comparison table.",
"profiles": [
"proxy",
"browser",
"lookup"
],
"frontmatter": "name: korean-law-search\ndescription: Search Korean statutes, articles, precedents, interpretations, and local ordinances via k-skill-proxy, and draft 신・구조문대비표 (old/new article comparison tables, optional HWPX). Use when the user asks for Korean law/article/precedent lookups or an amendment comparison table.\nlicense: MIT\nmetadata:\n category: legal\n locale: ko-KR\n phase: v1"
}
SKILL.md
---
name: korean-law-search
description: Search Korean statutes, articles, precedents, interpretations, and local ordinances via k-skill-proxy, and draft 신・구조문대비표 (old/new article comparison tables, optional HWPX). Use when the user asks for Korean law/article/precedent lookups or an amendment comparison table.
license: MIT
metadata:
category: legal
locale: ko-KR
phase: v1
---
# korean-law-search
<!-- k-skill:cli-stub — generated by scripts/generate-skill-stubs.js; edit skill.json / instruction.md instead -->
## Get the full instructions (required first step)
Run this and follow its output as the primary instructions for this skill:
```bash
npx -y @nomadamas/k-skill@0 instruct korean-law-search
```
The CLI detects the current runtime (Dolshoi vault/CloakBrowser vs generic) and prints only the applicable instructions, always up to date. Helper files bundled with the CLI are listed by:
```bash
npx -y @nomadamas/k-skill@0 files korean-law-search
```
Keep the CLI and every coding-agent skill install current (including Vercel Agent Skills) with:
```bash
npx -y @nomadamas/k-skill@0 update
```
If `npx` is unavailable, install Node.js 18+ or follow https://github.com/NomaDamas/k-skill#readme, or read the source instructions at https://github.com/NomaDamas/k-skill/blob/main/korean-law-search/instruction.md.
## Hard rules even without the CLI
- Never execute payment, message/email delivery, final submission, cancellation, or public posting without the user's explicit approval immediately beforehand.
- Never ask for, print, or store plaintext credentials in chat, files, or shell arguments.
- Never bypass legal, physical-presence, CAPTCHA, identity-proofing, or electronic-signature boundaries.