지자체 및 문화재단 웹사이트에서 PDF 문서(문화행사 정보 등)를 자동으로 수집하는 크롤링 서버
전국 지자체/문화재단 웹사이트는 구조가 제각각입니다. 어떤 사이트는 한 페이지에 PDF가 다 있고, 어떤 사이트는 글 목록을 클릭해야 하고, 어떤 사이트는 연도별 필터가 있습니다. 링크 방식도 <a href="">, onclick, javascript:, 커스텀 속성 등 다양합니다.
이 서버는 크롤링 대상 정보(Target)를 DB에 저장해두고, 저장된 설정을 기반으로 다양한 구조의 웹사이트를 자동으로 크롤링합니다.
- Java 21
- Spring Boot 3.x
- Spring Data JPA
- Selenium WebDriver (동적 페이지 처리)
- ChromeDriver
크롤링할 웹사이트의 정보를 담고 있는 엔티티입니다. 어떤 사이트를, 어떤 방식으로, 어떤 요소를 크롤링할지 모든 정보가 들어있습니다.
| 필드 | 설명 |
|---|---|
organizationType |
기관 유형 (지자체/문화재단) |
region |
지역 (예: 경상남도) |
group |
기관명 (예: 의령군) |
structureType |
페이지 구조 유형 |
pageUrl |
크롤링 시작 URL |
totalPage |
총 페이지 수 |
lst* |
목록(글 리스트) 요소 정보 |
pdf* |
PDF 링크 요소 정보 |
title* |
제목 요소 정보 |
year* |
연도 필터 요소 정보 |
nextPage* |
페이지네이션 요소 정보 |
크롤링 결과로 수집된 PDF 정보입니다.
| 필드 | 설명 |
|---|---|
target |
어떤 Target에서 수집했는지 |
pdfUrl |
PDF 다운로드 URL |
title |
PDF 제목 |
crawlingTime |
수집 시간 |
한 페이지에 PDF 링크와 제목이 모두 있는 경우
┌─────────────────────────────┐
│ [PDF] 2024년 문화행사 안내 │
│ [PDF] 2024년 축제 일정 │
│ [PDF] 공연 안내 │
│ 1 2 3 4 → │
└─────────────────────────────┘
처리 흐름:
- 페이지 접속
- PDF 링크들과 제목들 추출
- DB 저장
- 다음 페이지로 이동 (반복)
글 목록 형식으로, 각 글을 클릭해야 PDF가 있는 경우
┌─────────────────────────────┐
│ 📄 2024년 문화행사 안내 │ ← 클릭하면
│ 📄 2024년 축제 일정 │
│ 📄 공연 안내 │
└─────────────────────────────┘
↓
┌─────────────────────────────┐
│ 2024년 문화행사 안내 │
│ ─────────────────────── │
│ 첨부파일: [다운로드] │ ← 여기서 PDF 추출
└─────────────────────────────┘
처리 흐름:
- 목록 페이지 접속
- 각 글 링크 추출
- 글 하나씩 접속 → PDF 링크 추출 → DB 저장
- 다음 페이지로 이동 (반복)
연도별 필터를 선택해야 콘텐츠가 나오는 경우
┌─────────────────────────────┐
│ [2024] [2023] [2022] │ ← 연도 선택
│ ─────────────────────── │
│ [PDF] 문화행사 안내 │
│ [PDF] 축제 일정 │
└─────────────────────────────┘
처리 흐름:
- 페이지 접속
- 연도 버튼들 추출
- 각 연도 클릭 → SINGLE_PAGE 또는 LISTED_CONTENT 방식으로 처리
- 다음 연도로 이동 (반복)
웹사이트마다 링크를 구현하는 방식이 다릅니다.
일반적인 <a href="..."> 링크
<a href="/download/file.pdf">다운로드</a>onclick 이벤트로 동작하는 링크
<a onclick="fn_egov_downFile('FILE_001', '1')">다운로드</a>
<a onclick="fn_view('123', 'BBS001')">상세보기</a>지원하는 onclick 함수 (PDF):
fn_egov_downFile(atchFileId, fileSn)→/cmm/fms/FileDown.doyhLib.file.download(atchFileId, fileSn)→/common/file/download.dopdf_download(bcd, bn, num)→/fnc_bbs/user_bbs_downloadopenDownloadFiles(uid)→/programs/board/download.dogfnFileDownload(fileNo, fileNm)→/cmmn/file/downloadcf_downloadPDF(fmKeyNo)→/async/file/PDFdownload.dolocation.href='...'
지원하는 onclick 함수 (목록):
fn_view(nttId, bbsId)→/board/viewArticle.dofn_articleLink(boardIdx)→/kor/boardView.dogoTo.view(bIdx, ptIdx, mId)→/portal/bbs/view.dogoDetail(idx)→/portal/ebook/siboDetail.dofn_search_detail(nttId)→/prog/bbsArticle/.../view.do
href="javascript:..." 형태
<a href="javascript:goPage(2)">2</a>실제 URL 없이 커스텀 속성만 있는 경우
<div data-idx="123" data-file-key="abc">다운로드</div>지원하는 속성:
data-idxdata-req-get-p-idxdata-file-key
<select> 드롭다운으로 필터링하는 경우
<select>
<option value="2024">2024년</option>
<option value="2023">2023년</option>
</select>각 요소(목록, PDF, 제목, 페이지네이션)를 찾기 위해 CSS Selector를 사용합니다.
| 필드 | 설명 | 예시 |
|---|---|---|
parent*Identifier |
부모 요소 식별자 | boardList |
parent*TagType |
부모 요소 태그 | DIV, UL, TABLE |
parent*SelectorType |
식별 방식 | CLASS, ID, STYLE |
child*Identifier |
자식 요소 식별자 | item |
child*TagType |
자식 요소 태그 | A, LI, TR |
child*SelectorType |
식별 방식 | CLASS, ID, STYLE |
*OrdinalNumber |
n번째 자식 선택 | 1, 2, 0(전체) |
// 예: parentIdentifier="boardList", parentTagType=DIV, parentSelectorType=CLASS
// childIdentifier="", childTagType=A, ordinalNumber=0
// 결과: div.boardList > a
// 예: parentIdentifier="list", parentTagType=UL, parentSelectorType=ID
// childIdentifier="item", childTagType=LI, childSelectorType=CLASS, ordinalNumber=1
// 결과: ul#list > li.item:nth-child(1)특정 조상 요소 안에 있는 것만 필터링하고 싶을 때 사용합니다.
extended*Type: ON/OFF
extended*Identifier: 조상 요소 식별자
extended*TagType: 조상 요소 태그
extended*SelectorType: 식별 방식
목록 페이지에서 제목을 추출 (LISTED_CONTENT에서 사용)
[목록 페이지]
├── 제목1 ← 여기서 추출
├── 제목2
└── 제목3
상세 페이지에서 제목을 추출
[상세 페이지]
┌─────────────────┐
│ 제목: 문화행사 │ ← 여기서 추출
│ 첨부: [PDF] │
└─────────────────┘
POST /api/crawling
DB에 저장된 모든 Target을 순회하며 크롤링을 실행합니다.
| 메서드 | 엔드포인트 | 설명 |
|---|---|---|
| GET | /api/target/view |
전체 Target 조회 |
| POST | /api/target/register |
새 Target 등록 |
| PUT | /api/target/{targetId} |
Target 수정 |
| GET | /api/target/{targetId} |
크롤링 결과 조회 (수집 건수, 최근 수집 시간) |
{
"organizationType": "MUNICIPALITY",
"region": "경상남도",
"group": "의령군",
"structureType": "LISTED_CONTENT",
"lstType": "ONCLICK_LINK",
"parentLstIdentifier": "htoListCnt",
"parentLstTagType": "LI",
"parentLstSelectorType": "CLASS",
"childLstIdentifier": "",
"childLstTagType": "A",
"childLstSelectorType": "NONE",
"lstOrdinalNumber": 1,
"extendedLstType": "OFF",
"pageUrl": "https://www.uiryeong.go.kr/board/list",
"totalPage": 4,
"pdfType": "HREF_LINK",
"parentPdfIdentifier": "gnb",
"parentPdfTagType": "DIV",
"parentPdfSelectorType": "CLASS",
"childPdfIdentifier": "",
"childPdfTagType": "A",
"childPdfSelectorType": "NONE",
"pdfOrdinalNumber": 1,
"extendedPdfType": "OFF",
"titleType": "OUT",
"parentTitleIdentifier": "photoInfoBox",
"parentTitleTagType": "DIV",
"parentTitleSelectorType": "CLASS",
"childTitleIdentifier": "photoTitle",
"childTitleTagType": "P",
"childTitleSelectorType": "CLASS",
"titleOrdinalNumber": 1,
"extendedTitleType": "OFF",
"nextPageType": "HREF_LINK",
"parentNextPageIdentifier": "bdNumWrap",
"parentNextPageTagType": "DIV",
"parentNextPageSelectorType": "CLASS",
"childNextPageIdentifier": "",
"childNextPageTagType": "A",
"childNextPageSelectorType": "NONE",
"nextPageOrdinalNumber": 0,
"yearType": "NONE"
}{
"organizationType": "CULTURE_FOUNDATION",
"region": "서울특별시",
"group": "서울문화재단",
"structureType": "YEAR_FILTERED",
"yearType": "ONCLICK_LINK",
"parentYearIdentifier": "yearFilter",
"parentYearTagType": "DIV",
"parentYearSelectorType": "CLASS",
"childYearIdentifier": "",
"childYearTagType": "BUTTON",
"childYearSelectorType": "NONE",
"yearOrdinalNumber": 0,
"lstType": "HREF_LINK",
...
}src/main/java/Intern/moonpd_crawling/
│
├── MoonpdCrawlingApplication.java # 메인 클래스
│
├── config/
│ └── WebConfig.java # CORS 설정
│
├── constants/
│ └── KeywordConstants.java # onclick 함수명, 페이지 파라미터 등 상수
│
├── controller/
│ ├── CrawlingController.java # POST /api/crawling
│ └── TargetController.java # Target CRUD API
│
├── dto/
│ ├── request/
│ │ ├── TargetRegisterRequest.java # Target 등록 요청
│ │ └── TargetUpdateRequest.java # Target 수정 요청
│ └── response/
│ ├── TargetViewResponse.java # Target 목록 응답
│ └── CrawlingResultResponse.java # 크롤링 결과 응답
│
├── entity/
│ ├── Target.java # 크롤링 대상 정보
│ └── CrawlingData.java # 수집된 PDF 정보
│
├── exception/
│ ├── GlobalExceptionHandler.java
│ ├── NotFoundException.java
│ ├── DuplicateException.java
│ ├── CrawlingException.java
│ ├── WebDriverException.java
│ └── JavaScriptException.java
│
├── repository/
│ ├── TargetRepository.java
│ └── CrawlingDataRepository.java
│
├── service/
│ ├── CrawlingService.java # 메인 크롤링 로직 (Target 순회)
│ ├── CrawlDetailPageService.java # 상세페이지 크롤링
│ └── TargetService.java # Target CRUD
│
├── status/type/ # Enum 정의
│ ├── OrganizationType.java # MUNICIPALITY, CULTURE_FOUNDATION
│ ├── StructureType.java # SINGLE_PAGE, YEAR_FILTERED, LISTED_CONTENT
│ ├── LinkType.java # HREF_LINK, ONCLICK_LINK, PSEUDO_LINK, ...
│ ├── TagType.java # DIV, A, LI, TABLE, ...
│ ├── SelectorType.java # CLASS, ID, STYLE
│ ├── TitleType.java # IN, OUT
│ └── ExtendedType.java # ON, OFF
│
└── util/
├── CrawlConfluenceUtil.java # 링크 타입별 크롤링 분기 처리
├── CrawlPdfConfluenceUtil.java # PDF 링크 타입별 처리
├── ElementFinderUtil.java # CSS Selector로 요소 찾기
├── ElementFilterUtil.java # 유효한 링크만 필터링
├── ElementExtendedUtil.java # Extended 필터링
├── ElementCountUtil.java # 페이지/연도 수 계산
├── ElementLinkExtractorUtil.java # 링크 추출
├── NextPageLinkExtenderUtil.java # 페이지네이션 링크 확장
├── GapAwarePdfExtractorUtil.java # 제목-PDF 매칭 (빈 슬롯 처리)
│
├── structure/ # 페이지 구조별 처리
│ ├── SinglePageStructureUtil.java
│ ├── ListedContentStructureUtil.java
│ └── YearFilteredStructureUtil.java
│
├── lstCrawling/ # 목록 링크 처리
│ ├── HrefLinkLstUtil.java
│ ├── OnClickLinkLstUtil.java
│ ├── JavaScriptLinkLstUtil.java
│ └── PseudoLinkLstUtil.java
│
├── pdfCrawling/ # PDF 링크 추출
│ ├── HrefLinkPdfUtil.java
│ ├── OnClickLinkPdfUtil.java
│ ├── JavaScriptLinkPdfUtil.java
│ └── PseudoLinkPdfUtil.java
│
├── nextPageCrawling/ # 페이지네이션 처리
│ ├── HrefLinkNextPageUtil.java
│ └── OnClickLinkNextPageUtil.java
│
└── yearCrawling/ # 연도 필터 처리
├── HrefLinkYearUtil.java
└── OnClickLinkYearUtil.java
CrawlingController.startCrawling()
│
▼
CrawlingService.startCrawling()
│
├── Target 전체 조회
│
▼
┌────────────────────────────────────────┐
│ StructureType 분기 │
└────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
SINGLE_PAGE LISTED_CONTENT YEAR_FILTERED
│ │ │
▼ ▼ ▼
SinglePage ListedContent YearFiltered
StructureUtil StructureUtil StructureUtil
│ │ │
│ ▼ │
│ CrawlConfluenceUtil │
│ .crawlLst() │
│ │ │
│ ├── ONCLICK → OnClickLinkLstUtil
│ ├── HREF → HrefLinkLstUtil
│ ├── PSEUDO → PseudoLinkLstUtil
│ └── JAVASCRIPT → JavaScriptLinkLstUtil
│ │
│ ▼
│ CrawlDetailPageService
│ .crawlSubPage()
│ │
▼ ▼
CrawlPdfConfluenceUtil.crawlPdf()
│
├── ONCLICK → OnClickLinkPdfUtil
├── HREF → HrefLinkPdfUtil
├── PSEUDO → PseudoLinkPdfUtil
└── JAVASCRIPT → JavaScriptLinkPdfUtil
│
▼
CrawlingDataRepository.save()
// CrawlingService.java, CrawlDetailPageService.java
System.setProperty("webdriver.chrome.driver", "C:\\tools\\chromedriver-win64\\chromedriver.exe");// WebConfig.java
.allowedOrigins("http://localhost:5173") // 프론트엔드 주소application.yml에서 설정 (기본: H2 또는 MySQL)
# ChromeDriver 설치 필요
./gradlew bootRun이미 수집된 PDF URL은 다시 저장하지 않습니다.
if (crawlingDataRepository.existsByPdfUrl(pdfLink)) {
continue; // 스킵
}MIT License