새기능 #5803
이호영이(가) 약 한달 전에 변경
## 개요
중앙경찰학교 `알림/참여 > 공지사항` 게시판 **505건**(2006-06-05 ~ 2026-07-06)을 전건 수집해 `2. 권리처리` 시트에 기재했다.
- 목록: `https://www.cpa.go.kr/user/bbs/BD_selectBbsList.do?q_bbsCode=1001&q_rowPerPage=1000&q_currPage=1`
- 상세: `https://www.cpa.go.kr/user/bbs/BD_selectBbs.do?q_bbsCode=1001&q_bbscttSn=NNNNN`
## 분석
**페이지 수만큼 반복 요청하지 않고 1회 요청으로 전건을 받았다.**
목록 URL에 `q_rowPerPage`(페이지당 행 수) 파라미터가 있고, 서버가 이 값을 상한 검증 없이 그대로 받는 것을 확인했다. 기본 10 → **1000으로 지정해 51페이지를 1페이지로 압축**. 서버 부하도 줄고 페이징 경계에서 생기는 중복·누락 리스크도 없어졌다.
확인한 점
1. 공지 게시물 번호 컬럼은 `class="admin-notice show-col"` — 상단 고정공지와 일반글을 구분해야 함.
2. 게시물 고유키는 **`q_bbscttSn`** — 초기건은 5자리 숫자, 최근건은 `20260706165259718` 형태의 **타임스탬프형**으로 혼재 → 숫자로 캡스팅하면 안 되고 문자열 그대로 다뤄야 함.
3. HTML이 `&`로 이스케이프되어 있어 정규식에 반영 필요.
4. 사이드메뉴(LNB)의 `q_bbsCode` 매핑을 먼저 덤프해 **1001 = 공지사항** 임을 확인하고 진행.
## 핵심 코드
**게시판 코드 매핑 확인**
```python
import re
html = open('page.html', encoding='utf-8', errors='replace').read()
for code, name in re.findall(r'selectBbsList\.do\?q_bbsCode=(\d+)[^>]*>([^<]+)</a>', html):
if name.strip():
print(f' bbsCode {code}: {name.strip()}')
# -> bbsCode 1001: 공지사항
```
**1회 요청으로 전건 수집**
```bash
curl -s -k "https://www.cpa.go.kr/user/bbs/BD_selectBbsList.do?q_bbsCode=1001&q_rowPerPage=1000&q_currPage=1" \
-A "Mozilla/5.0" -o big.html
```
**목록 파싱**
```python
import re
doc = open('big.html', encoding='utf-8', errors='replace').read()
# q_bbscttSn 은 숫자/타임스탬프 혼재 -> 문자열로 취급
rows = re.findall(
r'<a href="BD_selectBbs\.do\?q_bbsCode=1001&q_bbscttSn=(\d+)"[^>]*>(.*?)</a>',
doc, re.S)
print('total post links:', len(rows), '| distinct sn:', len(set(sn for sn, _ in rows)))
```
**엑셀 반영 (`2. 권리처리` 시트)**
```python
import openpyxl, json, datetime
from copy import copy
posts = json.load(open('posts.json', encoding='utf-8'))
posts.sort(key=lambda x: int(x[0])) # 게시물 번호 오름차순
wb = openpyxl.load_workbook('중앙경찰학교.xlsx')
ws = wb['2. 권리처리']
SITE = '중앙경찰학교'
PATH = '알림/참여 > 공지사항'
BOARD = '공지사항'
BASE = 'https://www.cpa.go.kr/user/bbs/BD_selectBbs.do?q_bbsCode=1001&q_bbscttSn='
tmpl_styles = {c: copy(ws.cell(4, c)._style) for c in range(1, 13)} # 4행 서식을 템플릿으로
start, old_last = 4, 406
for r in range(start, max(start + len(posts) - 1, old_last) + 1): # 기존 예시데이터 정리
for c in range(1, 13):
ws.cell(r, c).value = None
for i, (num, sn, title, date, author) in enumerate(posts):
r = start + i
y, m, d = map(int, date.split('-'))
vals = {1: i + 1, 2: SITE, 3: PATH, 4: BOARD,
5: title, 6: BASE + sn, 8: datetime.datetime(y, m, d)}
for c in range(1, 13):
cell = ws.cell(r, c)
cell._style = copy(tmpl_styles[c])
if c in vals:
cell.value = vals[c]
wb.save('중앙경찰학교.xlsx')
```
## 사용 패키지
| 패키지 | 버전 | 용도 |
|---|---|---|
| Python | 3.13.0 | 실행 환경 |
| openpyxl | 3.1.5 | 엑셀 기재·서식 복사 |
| curl | - | 목록 HTML 다운로드 (`-k` SSL 검증 우회) |
표준 라이브러리: `re / json / datetime / copy / io / sys`
## 특이사항
- **`q_rowPerPage=1000` 트릭**이 핵심. 51회 요청 → 1회로 줄였다. 다만 서버가 항상 허용하는 것은 아니므로, 다른 사이트에선 반드시 응답 건수를 먼저 확인하고 써야 한다.
- 인증서 체인 이슈로 curl `-k`, Python 쪽은 `ssl.CERT_NONE` 컨텍스트로 요청.
- 시트 하단에 안내 문구(병합셀)가 있어 기존 행을 지우기 전에 **병합 해제 → 값/서식 백업 → 재기재** 순서로 처리했다.
- 게시물 고유키가 숫자형과 타임스탬프형으로 섞여 섮여 있어 **int 변환 없이 문자열로** 다뤄야 URL이 깨지지 않는다.
뒤로