프로젝트

일반

사용자정보

새기능 #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`). 분류명 기준(영상자료·간행물)으로 하면 게시판명이 중복되어 파일명이 충돌하기 때문.

뒤로