# 사이트맵 수집·정리 매뉴얼
공공저작물 실태조사 대상 기관의 홈페이지를 사이트맵 단위로 점검해 엑셀에 정리하는 작업의 **재현 가능한 절차서**.
한 기관이 끝나면 다음 기관도 이 문서만 보고 동일하게 만들 수 있어야 한다.
기준 양식: `자료_취합_예시.xlsx` (시트명 예: `04_구소영`).
완료 예시: `충청남도\1.계룡시\계룡시.xlsx`.
> # ⛔ 절대규칙 — `☑️ 검수완료` 기관은 손대지 마라
> 작업 전 반드시 `작업파일\_작업현황.md`를 먼저 읽고, **`☑️ 검수완료`로 표시된 기관은 재수집·수정·재계산·일괄작업·병합 무엇도 하지 않는다(읽기만).** 일괄 스크립트는 검수완료 대상을 반드시 제외한다. (현재: 계룡·공주·금산·논산)
---
## 0. 입력·산출물
### 입력
- **기관명** (예: "계룡시", "공주시")
- **베이스 URL** (예: `https://gyeryong.go.kr/kr/`)
- **저장 경로** (예: `충청남도\2.공주시\`)
- (선택) **담당자명** — 시트명 접두에 사용
### 산출물
- `{저장경로}\{광역}_{기관명}.xlsx` (예: `충청남도_계룡시.xlsx`. ★2026-06-01 파일명에 광역(도) 접두 부여 — 폴더명 `{번호}.{기관}`은 유지)
- 시트명: `{번호}_{기관명}` (예: `01_계룡시`)
- 행 1~2: `자료_취합_예시.xlsx`의 헤더 그대로 유지
- 행 3~끝: 사이트맵에서 추출한 데이터
---
## 1. 단계 개요
| 단계 | 결과 | 진행 여부 |
|---|---|---|
| Phase 1 | 사이트맵 → D~K열 채우기 + 병합·정렬·스타일 | ✅ 정립 완료 |
| Phase 2 | L(게시판형태) · M(수량) | ✅ 정립 완료 |
| Phase 3 | N(저작물 유형) | ✅ 정립 완료 |
| Phase 4 | O(공공누리 부착) · P(마크 부착위치) · Q(마크 하이퍼링크) | ✅ 정립 완료 |
| Phase 5 | R(공공누리 연계) · S(비고) · T~W(검토) · Y~AA(완료체크) | 📝 사용자 수기 영역 |
---
## Phase 1 — 사이트맵 수집·정리 (현재 정립된 절차)
> ### ⛔ 1-0. 권위 트리 = 사이트맵이 아니라 "본 페이지 메뉴(GNB/LNB)" (프리랜서2 #3·#16 — 최우선)
> **사이트맵에는 중간 메뉴가 누락된 경우가 매우 많다. 무조건 실제 홈페이지 메뉴(GNB 메가메뉴 / 좌측 LNB)를 권위 트리로 삼아 전수 작성한다.** 사이트맵은 보조 참고일 뿐. 사이트맵만 믿으면 페이지·게시판 누락이 대량 발생(프리랜서2 지적 #16).
> - 절차: GNB/LNB를 라이브로 펼쳐(필요 시 Playwright JS렌더) 대→중→소→세부 트리를 추출 → 사이트맵과 **합집합**(사이트맵에만 있는 것도 보존). 누락 0이 목표.
> - 본문 탭(1-5b/c)·카테고리탭까지 합쳐야 진짜 전수. "사이트맵에 없으니 없다"는 금지.
> - 실제 적용: 장수(41→356)·교육청 3곳·전주 GNB 4차 등 — 전부 사이트맵 누락을 GNB/LNB로 복구.
### 1-1. 사이트맵 페이지 찾기 (보조 입력)
베이스 URL의 메인 페이지 푸터/헤더에서 **"사이트맵"** 링크를 찾는다. (단 1-0대로 **본 페이지 메뉴가 권위**, 사이트맵은 보조)
- 일반 패턴: `/kr/html/guide/0701.html` (계룡시 등 다수 충남 시·군)
- 다른 패턴 예: `/sitemap.do`, `/html/sub06/sitemap.html`, `/sitemap.html`
- 못 찾으면 메인 페이지의 모든 `a` 태그에서 텍스트에 "사이트맵" / href에 `sitemap` 이 들어가는 것을 검색
### 1-2. 사이트맵 HTML 구조 파악
대부분의 시·군 홈페이지는 다음 두 패턴 중 하나를 따른다.
**패턴 A — `dl > dt > dd > ul > li` (계룡시 형)**
```
.sitemap
└─ dl (대분류 1개)
├─ dt → 대분류명 → D 메뉴명
└─ dd (중분류 1개)
├─ b > a → 중분류명 → E 카테고리(대)
└─ ul
└─ li > a → 소분류 → F 카테고리(중)
└─ ul (중첩!) → li > a → G 카테고리(소)
└─ ul → H, I, J ...
```
**패턴 B — `ul.depth1 > li.depth2 > ul.depth3` (단순 중첩 ul)**
- depth 1 → D, depth 2 → E, depth 3 → F, depth 4 → G ...
> **핵심**: `
` 안에 `
` 이 또 있을 수 있다(예: 계룡시의 `
`). 평면적으로 파싱하면 자식들이 F=빈칸으로 떨어지니, **반드시 재귀로 깊이를 추적**한다.
### 1-3. 컬럼 매핑
| 엑셀 열 | 의미 | 채워지는 값 |
|---|---|---|
| B | 순번 | 1부터 자동 부여, 행 삭제·삽입 후 재정렬 |
| C | 사이트명 | 기관명 고정 (예: "계룡시") |
| D | 메뉴명 | 사이트맵 최상위 (dt) |
| E | 카테고리(대) | 그 다음 깊이 |
| F | 카테고리(중) | 그 다음 깊이 |
| G | 카테고리(소) | 그 다음 깊이 |
| H | 카테고리(소·세부사항) | 그 다음 깊이 |
| I | 카테고리(소·세부사항2) | 그 다음 깊이 |
| J | 카테고리(소·세부사항3) | 가장 깊은 깊이 (그 이상은 J에 합침) |
| K | 주소(url) | 잎 노드의 href, 상대경로는 베이스로 절대화 |
| S | 비고 | 외부 도메인이면 "외부링크" |
### 1-4. URL 처리 규칙
- `javascript:...` , `#` → **빈 값**
- `http://`, `https://` 시작 → 그대로
- `/` 시작 → `https://{도메인}` + path 로 절대화
- 그 외 상대경로 → `urljoin(베이스, href)`
- **외부 판정**: `http(s)://` 이면서 도메인이 기관 도메인이 아니면 외부 → S열에 "외부링크"
### 1-5. 부모-자식 URL 중복 제거 (G 활성화 룰)
`
` 안에 중첩 `
` 이 있는 경우 부모 li의 a와 첫 자식 li의 a가 **같은 URL**을 가리키는 경우가 많다(예: 계룡시 "자동차 민원안내" = 01050601, 첫 자식 "신규등록" = 01050601).
규칙(★2026-05-30 전 깊이로 일반화):
1. 사이트맵 파싱 결과를 단순 행 리스트로 만든다 (부모 행 1개 + 자식 행 N개).
2. **임의 깊이**에서, 부모 행의 가장 깊은 카테고리 컬럼을 lc라 할 때(lc+1은 빈칸), 직후 자식 행이 ① D~lc 값이 모두 같고 ② lc+1 컬럼이 채워져 있고 ③ **K열 URL이 정확히 같으면** → **부모 행 삭제**. (D→E·E→F·F→G 모두 동일 적용. D=E=F가 같은 URL인 다중 중첩은 가장 깊은 행만 남김)
3. 카테고리 텍스트(D/E/F)는 자식 그룹 병합으로 살아남는다.
4. URL이 다르면 부모 행 그대로 둠.
> 기존 구현(phase1)은 **F→G 한 깊이만** 처리했다. 중분류(E)·대분류(D)가 클릭 랜딩이고 그 URL이 첫 하위행과 같은 사이트(서산시 등 다수 eGov)는 D→E·E→F 중복이 남아 있었음 → `_스크립트/_dedup_all.py`(dry/run)로 전 기관 일괄 정리(2026-05-30, 26개 기관 −791행). 신규 수집 시 phase1 단계에서 이 일반화 규칙을 적용할 것.
### 1-5b. 본문 탭(서브내비) → 하위 카테고리 확장 ★
사이트맵에는 한 줄로만 나오지만, **그 페이지 본문에 탭(서브내비)이 있어 실제로는 여러 하위 페이지를 거느리는 경우**가 많다(예: 공주시 "납세자보호관" 페이지에 `납세자보호관 / 납세자권리헌장 / 납세자보호관 업무추진실적` 3개 탭). 이 탭들도 **한 단계 아래 카테고리로 수집**한다. (leaf가 F면 탭은 G, E면 F …)
**사이트마다 탭 마크업이 다르므로 매 기관 본문을 직접 열어 패턴을 확인**한 뒤 셀렉터를 등록한다. 1-10에 기관별 탭 셀렉터를 기록.
> **확인 절차:** `_tab_expand.py`는 흔한 탭 클래스만 자동 탐지하므로, dry-run에서 **"확장 0건"이 나와도 탭 없음으로 단정하지 말 것.** 대표 페이지 2~3개의 본문을 직접 열어 탭 UL의 class를 확인 → `TAB_CLASS_PATS`에 없는 이름이면 추가 후 재실행. 본문 탭이 실제로 없을 때만 "탭 없음"으로 확정한다.
**확장 대상 판별 규칙(4조건 모두 충족할 때만 확장):**
1. **탭 컨테이너 클래스**가 본문에 있다. (공주시: `ul.tab-ul`. 흔한 후보: `tab-ul`·`tabmenu`·`tab-menu`·`tablist`·`subtab` 등. UL 자신의 class로 판별 — div 래퍼만 보고 오인하지 말 것)
2. 탭이 **2개 이상**이고, **모든 탭 href가 실제 페이지 링크**다. → `#anchor`(인페이지 스크롤 탭, 예 `#nav1`)·`javascript:`·빈 href·**쿼리스트링만 다른 필터 탭**(예 `?catecd=01`)은 **제외**.
3. **현재 페이지 URL이 탭 URL 집합에 포함**된다(= 자기 자신의 탭 그룹). 자기와 무관한 상위 nav 오인 방지.
4. 탭 중 **엑셀에 아직 없는 URL이 1개 이상**. → 탭들이 이미 전부 사이트맵 행으로 존재하면(상위 공통 nav) **제외**(중복 방지). 예: 공주시 정보공개 6탭 nav는 이미 F행으로 다 있으므로 재확장 안 함.
**확장 방식:**
- leaf 컬럼(E~J 중 가장 깊은 값)을 부모로 두고, 탭들을 **leaf+1 컬럼**에 **탭 순서대로** 채운다. 부모 F는 자식들에 걸쳐 병합된다.
- 현재 페이지와 **같은 URL인 탭("on" 탭) = 원래 행 재사용** → 이미 채운 L~T(Phase 2~4) 데이터 보존, leaf+1 컬럼에 그 탭 라벨만 기입.
- 나머지 새 탭 = **신규 행**(L~T 비움 → Phase 2~4 별도 수집 대상).
- "on" 탭 라벨이 부모(F)와 같아 G에 동일 텍스트가 한 번 더 들어가도 그대로 둔다(사용자 확정 방식).
> **일괄 적용(2026-05-29):** 수집완료 39개 기관 전체에 `_스크립트/_tab_batch.py`(probe·dry·run 모드)로 일괄 수행. base/domain은 엑셀 K열 자동도출, 영동군만 `--weak-ssl`. 확장 11곳(+1,240행). **전북 14곳·제주시·충남/충북 나머지는 본문 서브탭 실제 없음**(클래스 무관 탐지 `_tab_classfind.py`로 검증 — 반복 UL은 전역 패밀리/유틸 nav). `--weak-ssl` 옵션을 `_tab_expand.py`·`_tab_phase234.py`에 추가, dry먼저→신규행>0일 때만 write로 0건 불필요 재기록 방지.
**스크립트:** `_tab_expand.py <엑셀> <도메인키워드> [--write] [--weak-ssl]` — 인자 없이 실행하면 확장 계획만 출력(dry-run), `--write`로 백업(`*_backup_tab전.xlsx`) 후 기입. 신규 행 L~P 수집은 `_tab_phase234.py <엑셀> <도메인> [body_sel콤마]`:
> - **개별형**(공주시 `_phase234.py`, `get_body(soup)`): body_sel 생략 — 예 `_tab_phase234.py 공주시.xlsx 충청남도/2.공주시/_phase234.py gongju.go.kr`
> - **일괄형**(`_chungnam_phase234_all.py` 등, `get_body(soup, sel)`): body_sel 지정 — 예 `_tab_phase234.py 논산시.xlsx _chungnam_phase234_all.py nonsan.go.kr "#txt,#contents,main"`
> - get_body 인자 개수를 자동 감지해 두 형태 모두 대응. body_sel은 해당 모듈 `SITES[기관]['body_sel']` 값을 그대로 쓰면 됨.
> ⚠ fetch는 **바이트(`resp.content`)로 받아 BeautifulSoup가 meta charset으로 인코딩 자동판별**할 것. `apparent_encoding`은 일부 페이지(공주시 읍면동)에서 오판해 한글이 깨진다.
#### 1-5c. 탭 정밀화 — CMS 위젯 클래스·인페이지탭·카테고리탭 ★ (누적 재작업 교훈)
> ⚠ **"탭없음 0건"은 신뢰 금지.** 서산(0→577)·부여(222→351)·서귀포(367→502)·제주시(271→363) 모두 "탭없음" 오판이었다. 반드시 대표 페이지 본문 UL을 직접 확인.
- **위젯 클래스 변형 — `[class*=tab]`는 `taps`/`tap`(b 없는) 위젯을 통째로 놓친다.** 제주교육청 `ul.taps-dt4/taps-dt5(tapScroll)`가 broad `[class*='tab']`에서 누락됨(2026-06-12 발견). broad 스캔은 **`[class*='tab'], [class*='tap']`** 둘 다 매칭. 벤더별 실제 클래스: `tab-ul`(공주)·`tab_menu`(서산)·`basic_tab`(부여)·`ul.taps`(장수)·`div.tabmenu`(제주시·제주교육청)·`menu_tabmenu`(단양)·`ul.item3/item4`(진안)·`tabTy2/tab_st`(충남교육청)·`taps-dt4/dt5`(제주교육청).
- **인페이지(#앵커) 탭 2종 구분**:
- 단순 스크롤 내비(섹션 점프, 본문이 한 페이지) → **그 자체는 행 추가 안 함**(어문 섹션).
- **콘텐츠 섹션탭**(tapScroll/taps-dt5 등 실제 하위 설명섹션을 탭으로 나눔) → **페이지 행이면 M=탭수** 기입(인페이지탭=설명섹션). **게시판이면 M=총게시물 유지**(탭은 부가).
- **카테고리 탭(`categoryCode`)**: 한 게시판(boardId 동일)을 카테고리로 나눈 탭(`categoryCode1=A/B/C/D`, 예 학교자료 운영계획서/운영현황/자체평가보고서/학교헌장) → **각 카테고리를 leaf+1 별행**(게시판, M=카테고리별 총건수, boardId는 같아도 categoryCode로 구분). **"전체"(categoryCode 없는 board)·주석처리(``)된 li = 제외.**
- ⚠ **탭 ul 안의 li를 전부 분류하라.** 카테고리 탭 그룹에 **선행 일반메뉴(`index.jje?menuCd=` 소개/랜딩 페이지)** 가 섞여 있으면 그것도 별행(페이지)으로 수집 — 카테고리 4개만 보고 랜딩을 빠뜨리기 쉽다(학교자료 글로벌역량학교 등 15개 누락 사례, 2026-06-12). ⚠ 사이트 자체 오링크 주의(미래기술인재학교 첫 li가 타학교 menuCd를 가리킴 → 권위값=메뉴트리/소개페이지 menuCd 사용).
- ⚠ **탭 앵커는 행번호가 아닌 URL로 배치.** 랜딩셸 제거 등으로 행이 밀리면 옛 행번호(stale anchor) 기반 삽입이 탭을 엉뚱한 부모 밑에 흩뿌린다. 메뉴페이지를 라이브 broad스캔→self-탭 URL이 그 행이면 거기 앵커, 나머지는 자식. 기존 L/M/N/O는 **URL 매핑으로 보존**.
- **중첩 탭(탭의 탭)·한 페이지 다중 탭그룹**: self-그룹 전부 union 후 **fixpoint 반복 전개**(새 탭 0까지). 충남교육청 관·과장(바깥 부서대분류 + 안쪽 부서) 사례.
### 1-5d. 리다이렉트 중복 — URL(menuCd) 다른데 같은 페이지 ★
일부 사이트(예: 고창군 `index.gochang?menuCd=…`)는 **메뉴 카테고리(랜딩)를 클릭하면 실제 콘텐츠가 있는 자식 페이지로 자동 리다이렉트**한다. 그래서 "랜딩 행"과 "자식 행"이 **URL(menuCd)만 다르고 화면은 똑같다** — 같은 페이지인데 행이 둘로 부풀려진다.
> ⚠ **1-5(URL이 정확히 동일)로는 안 잡힌다.** URL 문자열이 다르기 때문. 리다이렉트로만 같은 페이지가 된다.
**탐지:** 각 행의 K URL을 **실제로 열어**(`requests` `allow_redirects=True`) **최종 `resp.url`을 비교**한다. 최종 URL(또는 최종 `menuCd`+`contentsSid`)이 같은데 행이 둘 이상이면 같은 페이지.
- 보조 신호: 두 행의 **게시물수 M 동일** + **F(또는 E)라벨 동일** + 한쪽이 카테고리 랜딩(`menuCd` 끝 `…000000` 또는 첫 자식).
**처리:** 리다이렉트해 **떠나가는 셸**(자기 `menuCd` ≠ 최종 `menuCd`, 그리고 최종 `menuCd`가 **다른 행으로 이미 존재**) 행을 **삭제**, 최종(타겟) 행은 유지. 셸의 카테고리 텍스트(D/E/F)는 자식행에 병합으로 살아남으므로 손실 없음. flatten→타겟 제외→D/E/F 재병합(1-6).
> 예(고창군 2026-05-31): 군정알림→공지사항·고창소식→보도자료·문화관광→관광공사·참여마당→고창군에바란다 등 **13건 셸 삭제(342→329)**. 전 행 fetch로 최종URL 충돌 탐지. 도구 `1.고창군/_del_dup.py`.
### 1-5e. 랜딩셸 삭제 — 병합 그룹 첫 행이 빈 다음깊이인 메뉴 껍데기 ★
카테고리 메뉴(대/중/소…)가 **자기 자식 목록만 보여주는 섹션 랜딩 페이지**일 때, 사이트맵에 그 랜딩이 한 행 + 자식들이 뒤따라 들어와서 **병합 그룹의 첫 행이 "껍데기"**가 된다. 이 첫 행을 삭제하고 카테고리 라벨은 첫 자식이 승계한다.
> ⚠ **1-5(URL 정확히 동일)·1-5d(리다이렉트로 같은 화면)로는 안 잡힌다.** 랜딩셸은 URL이 자식과 **다르고**(섹션 랜딩 `/field/welfare.do`·`/info/news.htm`式) 리다이렉트도 안 되는, **독립된 섹션 인덱스 페이지**다.
**삭제 조건 (4개 모두 충족):** 깊이 컬럼은 **D(대)→E(중)→F(소)→G→H→I→J(세부)**. 임의의 깊이 컬럼 **Cₙ**(n = D~I)에 대해
1. **Cₙ이 여러 행에 걸쳐 병합**돼 있고
2. 그 병합 그룹의 **첫 행에서 바로 다음 깊이 Cₙ₊₁(오른쪽 한 칸, 최대 J)이 비어있음**
3. **K가 섹션 랜딩 URL**(자식 목록만 거느린 카테고리 인덱스)
4. → 그 **첫 행(셸)만 삭제**, Cₙ 라벨은 첫 자식이 승계(병합 재계산).
즉 D병합+첫행 E빈칸 / E병합+첫행 F빈칸 / F병합+첫행 G빈칸 / G병합+첫행 H빈칸 … 전 깊이 동일하게 적용.
**⚠ 보존 예외 (삭제 금지):**
- **게시판·공공누리 부착 랜딩**(M 또는 O 값 있음) — 실제 콘텐츠.
- **단일행 리프**(병합 아닌데 다음깊이 빈칸) — 예산정보·민원발급정부24式 실제 페이지/사이트. **반드시 "병합된 그룹"일 때만** 셸로 본다.
- 1-5(URL 동일)·1-5d(리다이렉트)에 해당하면 그 룰로 처리.
**처리:** unmerge 전체 → 셸 앵커의 D~J 라벨을 첫 자식이 승계 → bottom-up 삭제 → 신규행 remap → K 하이퍼링크 value 재생성 → B 재부번 → 1-6 재병합. 검증: 잔여 셸 0·K링크 불일치/누락 0·병합겹침 0·B순번 연속.
**도구:** `_del_shell.py`(제주식 견고삭제, 전 깊이) · `_스크립트/_collapse_landing.py --safe`(전북 일괄, 순수 메뉴셸·페이지·M≤1·미부착만).
> 적용 이력: 전북 일괄 2026-05-31(`_collapse_landing.py` 98행) · 김제 −2 · 남원 −1 · 전주 −27 · 제주시 34행(E병합/F빈·`/field/welfare.do`) · 서귀포 36행(E병합/F빈·`/info/news.htm`) · 완주(검수時) · 정읍 23행(G병합/H빈, javascript 가짜탭 제거 병행). ⚠ 한 깊이 더 깊은 변형(G/H 등)도 같은 룰. 자식 전부 페이지면 부모로 **접는** 재귀합치기(`_collapse_recursive.py`, 고창 F그룹)는 **삭제가 아니라 병합**이라 별건.
### 1-6. 병합·정렬 규칙
#### 병합
- **D 열**: 같은 값이 연속되는 구간 전체를 병합 (대분류 단위)
- **E 열**: 같은 D 그룹 안에서 같은 E 값 연속 구간만 병합
- **F 열**: 같은 D+E 그룹 안에서 같은 F 값 연속 구간만 병합
- **G 이상**: 병합하지 않음
- **헤더 병합 보존**: `B1:R1`, `S1:W1`, `Y1:AA1` 세 개는 절대 풀지 말 것
- **★ 병합 순서: 반드시 F → E → D (D를 마지막에).** E·F 병합은 상위 컬럼(D, D+E) 값으로 그룹을 묶는데, D를 먼저 병합하면 openpyxl이 블록 2행부터 D를 None으로 비워 E 그룹키가 깨진다 → 각 대메뉴(D) 블록의 **첫 행 E가 단독으로 남는 누락** 발생. D를 맨 마지막에 병합하면 해결. (2026-05-29 발견·수정. 기존 산출물은 `_fix_emerge_all.py`로 재크롤링 없이 보정 완료)
#### 정렬
- 병합/단독 무관 — D · E · F · G 셀: **가로 가운데, 세로 가운데, 줄바꿈 ON**
- **K 열: 가로 왼쪽, 세로 가운데** (링크 본문 가독성)
- B(순번), Y/Z/AA(검토/완료) 등 헤더 기본 정렬 유지
### 1-7. 스타일 규칙
- **모든 행 높이 15** (헤더 1~2행 포함)
- **K열 하이퍼링크 활성화**:
- 셀의 `hyperlink` 속성에 URL 지정
- 폰트: 색 `0000FF`, 밑줄 `single`, 폰트명/크기는 기존 값 유지 (없으면 맑은 고딕 11)
- 외부링크인 행: S열에 "외부링크" 텍스트
- 셀 테두리·배경 등은 양식 파일의 기본 서식을 그대로 따른다
### 1-7b. 사용자 부분검수 보존 · 수정/신규셀 표기 · 국소삽입 ★ (누적 교훈)
재수집·정정 작업이 거의 항상 **이미 사용자가 일부 검수한 파일** 위에서 일어난다. 사용자 작업을 훼손하지 않는 것이 최우선.
- **부분검수 보존**: 사용자가 "N행까지(또는 1~N 순번) 검수했다/정답이다"라고 하면 그 범위는 **0셀 변경 보존**. 변경은 그 아래(또는 사용자가 지목한 범위)만. 작업 후 **diff로 보존 범위 0변경 검증**.
- **수정/신규셀 하늘색**: 내가 변경하거나 새로 추가한 셀은 `FFBDD7EE`(하늘색, `fg+bg 둘 다 지정` — fg만 주면 안 보임)로 표기해 사용자가 확인. **"적용은 내가 시킬 때"** 류 지시면 색칠만(=확인용)·실반영은 사용자 지시 시.
- **국소삽입(행 추가)**: 전체 재병합은 사용자 수동병합/B순번 스킴을 훼손하므로 회피. 절차 = **기존 병합 기록 → unmerge 전체 → `insert_rows` → generic remap(merge 시작:`+삽입수 dl > dt|dd > ul > li`, 중첩 `