새기능 #5806
이호영이(가) 약 한달 전에 변경
## 개요
KRIHS국토연구원(krihs.re.kr) **7개 게시판 / 2,247건**을 수집해 게시판별 개별 파일(7개)로 생성했다.
| 게시판 | 경로 | 건수 | 시트 |
|---|---|---|---|
| 보도자료 | 연구원소식 > 보도자료 | 1,034 | 권리확인 |
| 국토용어사전 | 국토교육 > 국토교육자료 | 807 | 권리처리 |
| 우수보고서 소개 | 연구원소식 > 우수보고서 소개 | 276 | 권리처리 |
| 지도로 보는 우리국토 | 국토교육 > 국토교육자료 | 77 | 권리처리 |
| 국토교육영상 | 국토교육 > 국토교육자료 | 40 | 권리처리 |
| 국토교육 워크북 | 국토교육 > 국토교육자료 | 8 | 권리처리 |
| 국토TED | 국토교육 > 국토교육자료 | 5 | 권리처리 |
## 분석
KRIHS는 eZ 계열 CMS로, **같은 사이트 안에서 게시판 타입이 5가지**로 갈렸다. 하나의 파서로 처리할 수 없어 타입별 파서를 분리했다.
| 타입 | 게시판 | 목록 구조 | 페이징 | 상세 URL |
|---|---|---|---|---|
| A. 테이블형 `board.es` | 보도자료 | `<table>` 행 | `nPage` | `act=view&list_no=` |
| B. 리포트형 `gallery.es` | 우수보고서 소개 | `ul.report_list` | `nPage` | `act=view&list_no=` |
| C. 갤러리형 `gallery.es` | 워크북 / 지도로 보는 우리국토 | `ul.blog_list` / `ul.gallery_list` | `nPage` | `act=view&list_no=` |
| D. 외부링크형 `gallery.es` | 국토교육영상 / 국토TED | `ul.gallery_list` | `nPage` | **유튜브 외부 URL** |
| E. 사전형 `bbsList.es` | 국토용어사전 | `<table>` 행 | **`pageIndex`** | `bbsView.es?num=` |
### 걸렸던 지점 4가지
**① 페이징 파라미터가 게시판마다 다름**
`.es` 계열은 `nPage`, 사전형만 `pageIndex`. 총 페이지 수도 전자는 `<strong>1</strong>/<b>104</b>` 표기, 후자는 페이징 링크 최대값으로 받아야 함.
**② 목록 제목이 서버에서 잘림(`..`)**
보도자료·우수보고서·지도로보는우리국토는 목록 제목이 잘려 나온다. 잘린 건만 골라 **상세페이지에서 전체 제목을 재취득**(병렬 8쓰레드). 전건 상세 조회 대비 요청을 크게 줄임.
**③ 상세페이지 제목 셀렉터가 게시판마다 다름**
대부분 `article.board_view > h2.title` 이지만 **우수보고서는 `article.blog_view > .report_info > strong.tit`**. 첫 시도 때 이걸 놓쳐 13건이 잘린 제목 그대로 남았고, 셀렉터를 폴백 리스트로 바꿔 해결.
**④ 중첩 `<li>` 때문에 항목 분할 실패**
우수보고서 목록은 항목 `<li>` 안에 저자·게시일이 다시 `<li>`로 들어있어 `<li>...</li>` 정규식 분할이 깨졌다(등록일 276건 전수 누락). **`list_no` 등장 위치를 기준으로 본문을 청크로 자르는 방식**으로 변경해 해결.
## 핵심 코드
**페이지 수 판정**
```python
def krihs_pages(url_tpl):
h = get(url_tpl.format(1))
m = re.search(r'<strong>1</strong>/<b>(\d+)</b>', h) # .es 계열 표기
if m:
return int(m.group(1)), h
idx = re.findall(r'(?:pageIndex|nPage)=(\d+)', h) # 사전형: 링크 최대값
return (max(int(x) for x in idx) if idx else 1), h
```
**중첩 `<li>` 회피 — `list_no` 기준 청크 분할**
```python
# 항목별로 list_no 를 기준으로 본문을 잘라낸다 (중첩 <li> 때문에 <li> 분할 불가)
marks, seen = [], set()
for m in re.finditer(r'act=view&(?:amp;)?list_no=(\d+)', body):
if m.group(1) not in seen:
seen.add(m.group(1))
marks.append((m.group(1), m.start()))
chunks = [(ln, body[st:(marks[i + 1][1] if i + 1 < len(marks) else len(body))])
for i, (ln, st) in enumerate(marks)]
for list_no, ch in chunks:
tm = (re.search(r'class="tit">(.*?)</strong>', ch, re.S) # report_list
or re.search(r'class="title[^"]*">(.*?)</strong>', ch, re.S)) # gallery_list
title = clean(tm.group(1))
dm = re.search(r'(\d{4}-\d{2}-\d{2})', ch)
items.append({'title': title,
'url' : f'{BASE}/gallery.es?mid={mid}&bid={bid}&act=view&list_no={list_no}',
'date' : dm.group(1) if dm else '',
'need_detail': title.endswith('..')}) # 잘린 제목 표시
```
**상세 제목 재취득 (셀렉터 폴백 + 병렬)**
```python
from concurrent.futures import ThreadPoolExecutor
DETAIL_TITLE_RES = [
re.compile(r'class="board_view">\s*<h2 class="title">(.*?)</h2>', re.S), # board.es / gallery.es 일반
re.compile(r'class="report_info">.*?<strong class="tit">(.*?)</strong>', re.S), # 우수보고서(0029)
re.compile(r'<article class="blog_view">.*?<strong class="tit">(.*?)</strong>', re.S),
]
def krihs_detail_title(url):
h = get(url)
for rx in DETAIL_TITLE_RES:
m = rx.search(h)
if m:
return clean(m.group(1))
return ''
def fill_titles(items, workers=8):
todo = [it for it in items if it.get('need_detail')] # 잘린 건만
with ThreadPoolExecutor(max_workers=workers) as ex:
list(ex.map(lambda it: it.update(title=krihs_detail_title(it['url']) or it['title']), todo))
return items
```
**사전형 (`pageIndex`) 파싱**
```python
tpl = f'{BASE}/krihsLibraryDictionary/bbsList.es?mid={mid}&pageIndex={{}}'
for row in re.findall(r'<tr>(.*?)</tr>', h[h.find('board_list'):], re.S):
m = re.search(r'href="(/krihsLibraryDictionary/bbsView\.es\?[^"]+)"[^>]*>(.*?)</a>', row, re.S)
if not m:
continue
num = re.search(r'num=(\d+)', m.group(1)).group(1)
items.append({'title': clean(m.group(2)),
'url' : f'{BASE}/krihsLibraryDictionary/bbsView.es?mid={mid}&num={num}', # 파라미터 정규화
'date' : re.findall(r'(\d{4}-\d{2}-\d{2})', row)[0]})
```
## 사용 패키지
| 패키지 | 버전 | 용도 |
|---|---|---|
| Python | 3.13.0 | 실행 환경 |
| openpyxl | 3.1.5 | 템플릿 복사·기재·서식·하이퍼링크 |
표준 라이브러리: `urllib.request / ssl / re / html / json / time / os / concurrent.futures`
## 검수
- 7개 게시판 전체 **잘린 제목(`..`) 0건 / 등록일 누락 0건 / URL 중복 0건**
- 무작위 표본을 실제 요청해 응답 HTML에 제목 포함 여부 대조 → 불일치 0건
- 하이퍼링크 생성 수 ↔ 데이터 행 수 일치 확인
## 특이사항
- **국토교육영상·국토TED는 내부 상세페이지가 없다.** 목록 썸네일을 누르면 유튜브로 바로 나가는 구조라 F열(게시물 내용 URL)에 유튜브 주소가 들어간다. 기관 확인 필요.
- 게시판별 공공누리 부착 현황을 페이지 하단 `div.kogl` 에서 확인해둔 상태 (G열은 현재 공란)
- **1유형**: 국토교육 워크북
- **4유형**: 국토교육영상, 국토TED, 국토용어사전, 지도로 보는 우리국토
- **미부착**: 보도자료, 우수보고서 소개
- 제출 양식의 `1-1.작성안내`에 따라 등록일은 **8자리 숫자**(20260714)로 기재. 템플릿 샘플 데이터는 `2026-07-14` 문자열로 되어 있어 서로 다름 달람 → 작성안내 기준을 따름. 따람.
- 사이트 표기 게시판명으로 파일명을 지정(예: `…(KRIHS국토연구원)_국토용어사전.xlsx`). 분류명 기준(영상자료·간행물)으로 하면 게시판명이 중복되어 파일명이 충돌하기 때문.
뒤로