# CNU 도서관 → lawnb 판례 검색·일괄 다운로드 # (기존 Python/Selenium 크롤러의 DSL 이식판) # # 실행 예: # crawler run scripts/lawnb_cases.yaml -p username=학번 -p password=비밀번호 -p query=손해배상 # 대시보드에서는 잡 생성 시 params로 실제 값을 넣으세요 (YAML에 비밀번호를 저장하지 말 것). meta: name: lawnb_case_downloads description: > CNU 도서관 로그인 → lawnb 진입 → 판례 검색(사건명/선고일) → 결과 전 페이지의 판례 URL 수집 → 각 판례 원문을 Word로 다운로드 start_url: "https://library.cnu.ac.kr/login" params: username: "201610058" password: "jAqqe8-wewrem-nuwgob" query: "가등기, 본등기" # 사건명 검색어 date_from: "19900101" # 선고일 시작, YYYYMMDD (예: 20230101) — 비우면 전체 기간 date_to: "20260731" # 선고일 종료, YYYYMMDD # 사건종류 필터: 헌법/민사/형사/가사/행정/특허/선거 중 선택 (빈 리스트 []면 전체) case_type: ["민사"] # 법원등급 필터: 화면 라벨 그대로 나열 (빈 리스트 []면 전체) # 체크박스 value로도 매치되므로 "대법"/"전합"/"고법"/"지법" 같은 코드값도 사용 가능 court_type: ["대법원", "대법원(전원합의체)", "고등법원", "지방법원"] limits: max_pages: 100 # 검색 결과 페이지 수 상한 max_items: 2000 # 수집 판례 수 상한 step_timeout_s: 15 page_load_timeout_s: 60 heal_attempts: 2 output: format: jsonl fields: [title, url] login: - action: navigate url: "${meta.start_url}" - action: type selector: { css: "input#id", intent: "도서관 로그인 폼의 학번/아이디 입력 필드" } text: "${username}" - action: type selector: xpath: "//*[@id='login']/fieldset/div[2]/dl[2]/dd/input" css: "#login input[type=password]" intent: "도서관 로그인 폼의 비밀번호 입력 필드" text: "${password}" - action: click selector: xpath: "//*[@id='login']/fieldset/div[2]/p/input" intent: "도서관 로그인 폼의 로그인 제출 버튼" - action: sleep seconds: 3 # 도서관 프록시(outlink)를 거쳐 lawnb로 이동 — 세션 인증이 전달됨 - action: navigate url: "https://library.cnu.ac.kr/outlink?moduleId=schosite&targetUrl=https://cnu.lawnb.com" - action: sleep seconds: 5 # 신규 UI가 뜨면 '기존 화면'으로 전환 (이미 구 UI면 그냥 통과) - action: if exists: xpath: "/html/body/header/div/div/button[1]" intent: "lawnb 신규 UI 상단의 '기존 화면' 전환 버튼" then: - action: click selector: xpath: "/html/body/header/div/div/button[1]" intent: "lawnb 신규 UI 상단의 '기존 화면' 전환 버튼" - action: sleep seconds: 3 collect_urls: - action: navigate url: "https://academynext-lawnb-com.libra.cnu.ac.kr/Info/ContentMain/Case" - action: wait_for selector: { css: "#sSubWord5", intent: "판례 검색 페이지의 사건명 검색어 입력창" } - action: type selector: { css: "#sSubWord5", intent: "판례 검색 페이지의 사건명 검색어 입력창" } text: "${query}" - action: type optional: true selector: { css: "#cDate1", intent: "선고일 검색 범위의 시작일(YYYYMMDD) 입력 필드" } text: "${date_from}" - action: type optional: true selector: { css: "#cDate2", intent: "선고일 검색 범위의 종료일(YYYYMMDD) 입력 필드" } text: "${date_to}" # 사건종류 필터 — params.case_type의 각 항목을 라벨 텍스트/값으로 찾아 체크. # [not(@checked)] 가드: 이미 켜진 박스는 매치되지 않아 재클릭(해제)을 방지. - action: foreach over: case_type as: ct return_to_list: false steps: - action: click optional: true heal: false timeout_s: 3 selector: xpath: >- //input[@name='cType2'][not(@checked)][@value='${ct}' or normalize-space(following-sibling::text()[1])='${ct}'] | //label[normalize-space(.)='${ct}']//input[@name='cType2'][not(@checked)] | //input[@name='cType2'][not(@checked)][@id = //label[normalize-space(.)='${ct}']/@for] intent: "사건종류 필터에서 '${ct}' 항목의 체크박스(미선택 상태)" # 법원등급 필터 — params.court_type의 각 항목을 라벨 텍스트/값으로 찾아 체크. - action: foreach over: court_type as: court return_to_list: false steps: - action: click optional: true heal: false timeout_s: 3 selector: xpath: >- //input[@name='cType3'][not(@checked)][@value='${court}' or normalize-space(following-sibling::text()[1])='${court}'] | //label[normalize-space(.)='${court}']//input[@name='cType3'][not(@checked)] | //input[@name='cType3'][not(@checked)][@id = //label[normalize-space(.)='${court}']/@for] intent: "법원등급 필터에서 '${court}' 항목의 체크박스(미선택 상태)" - action: click selector: { css: "#searchBtn", intent: "판례 검색 실행 버튼" } - action: wait_for selector: { css: ".currentPage", intent: "검색 결과 하단 페이지네이션의 현재 페이지 번호 표시" } - action: paginate next_selector: css: ".currentPage + a" intent: "현재 페이지 번호 바로 다음에 오는 이동 링크 (다음 페이지 번호 또는 다음 페이지 묶음)" stop_if_missing: true steps: - action: extract_list save_as: cases item_selector: css: "article ol > li" xpath: "/html/body/div/div/div[1]/div[2]/article/div[1]/div[4]/ol/li" intent: "판례 검색 결과 목록에서 판례 한 건에 해당하는 리스트 항목" fields: - name: title selector: { css: "h3 a", intent: "판례 제목 링크의 텍스트" } - name: url selector: { css: "h3 a", intent: "판례 상세 페이지로 가는 링크" } attr: href absolute_url: true required: true collect_data: - action: foreach over: cases as: item return_to_list: false steps: - action: navigate url: "${item.url}" - action: sleep seconds: 2 - action: if exists: xpath: "/html/body/div[1]/div[1]/header/div/div/a[3]" intent: "판례 뷰어 상단 헤더의 저장(다운로드 창 열기) 버튼" then: - action: click selector: xpath: "/html/body/div[1]/div[1]/header/div/div/a[3]" intent: "판례 뷰어 상단 헤더의 저장(다운로드 창 열기) 버튼" # 저장 대화상자의 '링크 표시' 옵션이 켜져 있으면 해제 - action: if exists: xpath: "/html/body/div[1]/div[1]/div[3]/div/div[2]/div[2]/div/div[2]/input[1][@checked]" intent: "저장 대화상자에서 켜져 있는 링크 표시 옵션 체크박스" then: - action: click selector: xpath: "/html/body/div[1]/div[1]/div[3]/div/div[2]/div[2]/div/div[2]/input[1]" intent: "저장 대화상자의 링크 표시 옵션 체크박스" # 파일 형식은 기본값(PDF)으로 그대로 두고 바로 다운로드. # PDF로 파일이 안 나오면 alt_formats로 MS Word(.doc)를 눌러 재시도. # (형식 라디오: PDF=0 / HWP=1 / MS Word=2 / TXT=3, value 기준) - action: download selector: xpath: "/html/body/div[1]/div[1]/div[3]/div/div[2]/div[2]/div/div[8]/div/button" intent: "저장 대화상자의 최종 다운로드 실행(저장) 버튼" wait_s: 20 alt_formats: - css: "input[type=radio][value='2']" intent: "저장 대화상자의 파일 형식 'MS Word(.doc)' 라디오 (PDF 실패 시 대체)" else: # 구형 ebook 뷰어(iframe 내부 다운로드)는 DSL이 iframe 전환을 # 지원하지 않아 처리 불가 — 증적 스크린샷만 남기고 다음 판례로 - action: screenshot name: "ebook_viewer_unsupported"