package downloader; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.FileOutputStream; import java.io.InputStream; import java.net.HttpURLConnection; import java.net.SocketTimeoutException; import java.net.URL; import java.net.URLEncoder; import java.util.Arrays; import java.util.List; import java.util.concurrent.ForkJoinPool; import java.util.logging.Level; import java.util.stream.Collectors; import java.util.stream.Stream; import ch.qos.logback.classic.Logger; import common.MaruLoggerFactory; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; import org.jsoup.Connection.Response; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import com.gargoylesoftware.htmlunit.HttpMethod; import com.gargoylesoftware.htmlunit.WebClient; import com.gargoylesoftware.htmlunit.WebRequest; import com.gargoylesoftware.htmlunit.html.HtmlPage; import com.gargoylesoftware.htmlunit.util.NameValuePair; import common.ErrorHandling; import sys.Configuration; import sys.SystemInfo; import util.ImageCompress; import util.ImageMerge; import util.UserAgent; public class Downloader implements AutoCloseable { private Downloader() {} // DCL 싱글톤 패턴 private static volatile Downloader instance = null; public static Downloader getInstance() { if (instance == null) { synchronized (Downloader.class) { if (instance == null) { instance = new Downloader(); } } } return instance; } //만화 제목 = 폴더 이름 private final String PASSWORD = "qndxkr"; //만화 비밀번호 private final String DOMAIN = "http://wasabisyrup.com"; final int BUF_SIZE = 1048576; final int MAX_WAIT_TIME = 30000; //최대 대기시간 30초 private static final Logger print = MaruLoggerFactory.getPrintLogger(); /** * 선택된 페이지들만 다운로드 * * @param archiveAddress 전체 아카이브 주소가 담긴 리스트(어레이리스트 권장) * @param pages 선택된 페이지들의 번호가 담긴 리스트 */ public void selectiveDownload(List<Comic> archiveAddress, List<Integer> pages) { try { Comic comics[] = archiveAddress.toArray(new Comic[archiveAddress.size()]); // 선택한 페이지들만 가져와서 옮겨담음 List<Comic> selectedArchiveAddress = pages.stream() .map(x -> comics[x]) .collect(Collectors.toList()); //선택된 페이지들로 구성된 리스트들만 다운로드 시도 download(selectedArchiveAddress); } catch (Exception e) { ErrorHandling.saveErrLog("선택적 다운로드 실패", "", e); } } /** * 순수 이미지 주소가 담긴 {@code List}를 가지고 * HttpURLConnection을 이용해 실제 다운로드 및 저장을 담당<br> * Private Inner Class와 Parallel Stream 을 이용한 멀티 스레딩 다운로드 * * @param archiveAddress 다운받을 만화 아카이브 주소들이 담긴 리스트(ex. 원피스 1화~3화 아카이브주소) */ public void download(List<Comic> archiveAddress) { //아카이브 리스트에 담긴 개수 출력 print.info("총 {}개\n", archiveAddress.size()); String path, subFolder; //subFolder = 원피스 3화 // http://www.shencomics.com/archives/533456와 같은 아카이브 주소 for (Comic comic : archiveAddress) { print.info("다운로드 시도중...\n"); // 아카이브주소를 바탕으로 이미지 URL 파싱해 comic객체 내부에 저장 if (parseImageURL(comic) == false) { //페이지 파싱 실패 -> 건너뛰고 다음 주소 시도 continue; } // 아카이브주소에서 파싱한 이미지들의 URL이 담긴 리스트 List<String> imgList = comic.getImgURL(); subFolder = (comic.getTitle() + " " + comic.getTitleNo()).trim(); /* 저장경로 = "기본경로\제목\제목 n화\" = "C:\Marumaru\제목\제목 n화\" 또는, * 저장경로 = "사용자 설정 경로\제목\제목 n화\" = "C:\Marumaru\제목\제목 n화\" */ path = String.format("%s/%s/%s/", SystemInfo.PATH, comic.getTitle(), subFolder); int pageNum = 0; // 매 회차 당 0으로 초기화 필수 int numberOfPages = imgList.size(); //전체 이미지의 개수 SystemInfo.makeDir(path); // 저장경로 폴더 생성 print.info("제목 : {}\n", comic.getTitle()); print.info("다운로드 폴더 : {}\n", path); print.info("다운로드 시작 (전체 {}개)\n", numberOfPages); Worker workers[] = new Worker[numberOfPages]; // 다운로드용 inner class 객체 // 다운로드 필수 정보들 주입 for (String imgURL : imgList) { workers[pageNum] = new Worker(imgURL, path, subFolder, ++pageNum, numberOfPages); } // 사용가능한 코어 수. 최소 1개는 보장 final int CORE_COUNT = Math.max(1, Runtime.getRuntime().availableProcessors()); int numberOfThreads, multi = Configuration.getInt("MULTI", 2); // value of MULTI property /* 0: Sequential (Single Thread Download) * 1: Thread count = available core count / 2 * 2: Thread count = available core count (DEFAULT) * 3: Thread count = available core count * 2 * 4: Thread count = Unlimited (Equal to numberOfPages) */ if (multi == 0) numberOfThreads = 1; else if (multi == 1) numberOfThreads = CORE_COUNT >>> 1; else if (multi == 2) numberOfThreads = CORE_COUNT; else if (multi == 3) numberOfThreads = CORE_COUNT << 1; else numberOfThreads = numberOfPages; // 스레드 개수가 전체 페이지 수를 초과하지 않게 조정 numberOfThreads = Math.min(numberOfThreads, numberOfPages); // 다운로드 스트림 Stream<Worker> downloadStream = Arrays.stream(workers).parallel(); ForkJoinPool pool = new ForkJoinPool(numberOfThreads); // 1: Sequential, N: Parallel try { pool.submit(() -> { downloadStream.forEach(w -> w.run()); // start() -> run() }).get(); // Blocking until finished (= Join) } catch (Exception e) { ErrorHandling.saveErrLog("다운로드 실패", "제목: " + subFolder, e); } /* 다운받은 만화들을 하나로 합치는 property 값이 true면 합침(기본: false) */ try { Configuration.refresh(); if (Configuration.getBoolean("MERGE", false)) { ImageMerge.mergeAll(path, subFolder); } } catch (Exception e) { ErrorHandling.saveErrLog("이미지 병합 실패", "", e); } /* 다운받은 만화들을 압축한다. */ try { Configuration.refresh(); if (Configuration.getBoolean("ZIP", false)) { ImageCompress.compress(path); } } catch (Exception e) { ErrorHandling.saveErrLog("다운받은 만화 압축 실패", "", e); } } } /** * Comic 타임 객체에 담긴 아카이브 주소(address)를 바탕으로 * 해당 페이지에 포함된 모든 이미지의 URL을 파싱해 저장하는 메서드. * * @param comic download()메서드에서 불릴 comic 객체 */ private boolean parseImageURL(Comic comic) { try { String pageSource = getHtmlPage(comic.getAddress()); print.info("이미지 추출중...\n"); //Html코드 파싱 후 Jsoup doc 형식으로 생성 Document doc = Jsoup.parse(pageSource); /* <span class=title-subject, title-no> 태그를 바탕으로 만화 제목 추출 * 제목은 폴더명으로 사용되므로 폴더명생성규칙 위반되는 특수문자 제거 */ comic.setTitle(removeSpecialCharacter(doc.select("span.title-subject").first().text())); comic.setTitleNo(doc.select("span.title-no").first().text()); /* <img class="lz-lazyload" src="/template/images/transparent.png" data-src="/storage/gallery/OrXeaIqMbEc/m0035_T6THtV9OvWI.jpg"> * 위의 data-src부분을 찾아 attribute만 추출하여 List에 담고, 최종적으로 comic 객체 내부에 옮김 */ List<String> imgURL = doc.select("img[data-src]").stream() .map(x -> DOMAIN + encoding(x.attr("data-src"))) //아카이브 이름을 항상 최신으로 정해놓고 시작 .collect(Collectors.toList()); comic.setImgURL(imgURL); //파싱한 이미지 파일들의 URL을 comic객체에 저장 } catch (Exception e) { /* Jsoup, HtmlUnit을 모두 사용했으나 페이지 파싱에 실패한 경우 * 또는 태그 추출 과정에서 에러가 발생하여 이미지 파싱이 실패한 경우 */ ErrorHandling.saveErrLog("ImageURL 파싱 실패!", "페이지 주소: " + comic.getAddress(), e); return false; } return true; } /** * <b> Archive의 Html Source code를 가져오는 메서드</b> * <ol>1. Jsoup을 이용하여 아카이브 고속 파싱 시도</ol> * <ol>2. 실패하면({@code div.gallery-template}가 없으면) HtmlUnit을 이용해 아카이브 일반 파싱 시도</ol> * <ol>3. 파싱된 Html코드를 포함한 소스 전문을 스트링값에 담아서 리턴</ol> * * @param eachArchiveAddress 실제 만화가 담긴 아카이브 주소 * @return 이미지(.jpg) 주소가 포함된 Archive의 HTML 소스코드 */ private String getHtmlPage(String eachArchiveAddress) throws Exception { String pageSource = null; try { //우선 Jsoup을 이용한 고속 파싱 시도 pageSource = getHtmlPageJsoup(eachArchiveAddress); } catch (Exception e) { ErrorHandling.saveErrLog("Jsoup 파싱 실패", eachArchiveAddress, e); } try { //실패시 null이 담겨있고, HtmlUnit이용해 일반 파싱 재시도 if (pageSource == null) pageSource = getHtmlPageHtmlUnit(eachArchiveAddress); } catch (Exception e) { ErrorHandling.saveErrLog("HtmlUnit 파싱 실패", eachArchiveAddress, e); } //Jsoup과 HtmlUnit 모두 파싱 실패시 에러메세지 출력 if (pageSource == null) { throw new RuntimeException("All Page Parsing Failed"); } return pageSource; //아카이브 페이지를 파싱한 결과 리턴 } /** * Jsoup을 이용한 HTML 코드 파싱. * * @param eachArchiveAddress 실제 만화가 담긴 아카이브 주소 * @return 성공하면 html 코드를 리턴 */ private String getHtmlPageJsoup(String eachArchiveAddress) throws Exception { print.info("고속 연결 시도중...\n"); // pageSource = Html코드를 포함한 페이지 소스코드가 담길 스트링, domain = http://wasabisyrup.com <-마지막 / 안붙음! String pageSource = null; // POST방식으로 아예 처음부터 비밀번호를 body에 담아 전달 Response response = Jsoup.connect(eachArchiveAddress) .userAgent(UserAgent.getUserAgent()) .header("charset", "utf-8") .header("Accept-Encoding", "gzip") //20171126 gzip 추가 .timeout(MAX_WAIT_TIME) // timeout .data("pass", PASSWORD) // 20180429 기준 마루마루에서 reCaptcha를 사용하기에 의미없음 .followRedirects(true) .execute(); Document preDoc = response.parse(); //받아온 HTML 코드를 저장 // <div class="gallery-template">이 만화 담긴 곳. if (preDoc.select("div.gallery-template").isEmpty()) { throw new RuntimeException("Jsoup Parsing Failed: No tag found"); } else { // 만약 Jsoup 파싱 시 내용 있으면 성공 pageSource = preDoc.toString(); } print.info("고속 연결 성공!\n"); return pageSource; //성공 시 html코드 리턴 } /** * HtmlUnit을 이용한 HTML 코드 파싱. * * @param eachArchiveAddress 실제 만화가 담긴 아카이브 주소 * @return 성공 시 html 코드를 리턴 */ private String getHtmlPageHtmlUnit(String eachArchiveAddress) throws Exception { /* 필수! 로그 메세지 출력 안함 -> HtmlUnit 이용시 Verbose한 로그들이 너무 많아서 다 끔 */ java.util.logging.Logger.getLogger("com.gargoylesoftware").setLevel(Level.OFF); System.setProperty("org.apache.commons.logging.Log", "org.apache.commons.logging.impl.NoOpLog"); print.info("일반 연결 시도중...\n"); WebClient webClient = new WebClient(); webClient.getOptions().setRedirectEnabled(true); WebRequest req = new WebRequest(new URL(eachArchiveAddress)); req.setHttpMethod(HttpMethod.POST); req.setAdditionalHeader("User-Agent", UserAgent.getUserAgent()); req.setAdditionalHeader("Accept-Encoding", "gzip"); //20171126 gzip 추가 req.getRequestParameters().add(new NameValuePair("pass", PASSWORD)); //비밀번호 post 방식 전송 HtmlPage page = webClient.getPage(req); //Html코드를 포함한 페이지 소스코드가 담길 스트링 String pageSource = page.asXml(); /** 여기도 페이지 파싱 실패 시 검증하는 코드 들어가야 됨 **/ webClient.close(); print.info("일반 연결 성공\n"); return pageSource; } /** * 이미지 URL에 영어 이외의 문자(ascii값이 256 이상)가 포함된 경우 * UTF-8로 인코딩 시켜주는 메서드 * * @param url 이미지 URL * @return UTF-8 형식의 이미지 URL */ private static String encoding(String url) { final StringBuilder utf8 = new StringBuilder(url.length() << 1); url.chars().forEach(x -> { String enc = ((char) x) + ""; if (x == ' ') { enc = "%20"; // 띄어쓰기도 변환 } else if (256 <= x) { // ASCII의 범위는 [0,255] -> 이걸 초과하는 영문 이외 글자를 변환 try { enc = URLEncoder.encode(enc, "UTF-8"); } catch (Exception e) { //지원하지 않는 인코딩인 경우 캐치 ErrorHandling.saveErrLog("UTF-8 변환 실패", "URL: " + url, e); } } utf8.append(enc); }); return utf8.toString(); } /** * <b>특수문자 제거 메서드</b><br> * {@code \ / : * ? < > | . }는 공백으로 대체됨<br> * {@code " }는 {@code ' }로 대체됨 * * @param rawText 특수문자가 포함된 스트링 * @return 특수문자가 제거된 스트링 */ private static String removeSpecialCharacter(String rawText) { return rawText.replaceAll("[\\\\/:*?<>|.]", " ") .replaceAll("[\"]", "'") .trim(); } /** * 다운로드 속도를 스트링 형식으로 반환 * <i>ex) 3.21 MB/s</i> * * @param byteSize 다운로드 받을 파일의 바이트 사이즈 * @param milliElapsed 다운로드에 걸린 시간(밀리초) * @return 다운로드 속도의 스트링 포맷 */ private static String getStrSpeed(long byteSize, long milliElapsed) { String unit[] = {"B", "KB", "MB", "GB", "TB"}; if (milliElapsed == 0) { milliElapsed = 1; } double spd = (byteSize * 1000 / milliElapsed); int i; for (i = 0; spd >= 1000; i++, spd /= 1000) ; return String.format("%6.2f %s/s", spd, unit[i]); } /** * 이미지 주소에서 마지막 {@code . }을 기준으로 확장자 추출(없다면 jpg로 디폴트) * * @param imgUrl 이미지 주소 * @param def 확장자를 찾지 못했을 경우 적용할 기본 확장자 * @return {@code . }을 포함한 확장자 */ private static String getExt(String imgUrl, String def) { int lastIndexOfDot = imgUrl.lastIndexOf("."); return lastIndexOfDot == -1 ? def : imgUrl.substring(lastIndexOfDot); } /** * 소멸자 */ @Override public void close() { instance = null; } /** * 다운로드 전용 private inner class * * @author occidere */ private class Worker extends Thread { private final String imgURL, path, subFolder; private final int pageNum, numberOfPages; private String host; private String referer; public Worker(String imgURL, String path, String subFolder, int pageNum, int numberOfPages) { this.imgURL = imgURL; // http://wasabisyrup.com/storage/gallery/fTF1QkrSaJ4/P0001_9nmjZa2886s.jpg this.path = path; this.subFolder = subFolder; this.pageNum = pageNum; // 페이지 번호는 001.jpg, 052.jpg, 337.jpg같은 형식 this.numberOfPages = numberOfPages; this.host = StringUtils.substringBetween(imgURL, "http://", "/"); this.referer = StringUtils.substringBeforeLast(imgURL, "/"); } @Override public void run() { try { //try...catch를 Worker 내부에 사용해서 이미지 한개 다운로드가 실패해도 전체가 종료되는 불상사 방지 long st = System.currentTimeMillis(); int imageSize = download(); long elapsed = (System.currentTimeMillis() - st); // TODO System.out.print 들 전부 print 적용해야 함. print.info("{}", String.format("%3d / %3d ...... 완료! (%s)", pageNum, numberOfPages, getStrSpeed(imageSize, elapsed))); // DEBUG값이 true이면 다운받은 이미지 용량 & 메모리 정보, 스레드 & 날짜 정보 출력 if (Configuration.getBoolean("DEBUG", false) == true) { print.info("{}\n", String.format("[%3d KB]", imageSize / 1000)); util.MemInfo.printMemInfo(); //메모리 정보 출력(줄바꿈 안함) print.info("\n(Thread Info: {})", Thread.currentThread()); // 스레드 정보 출력 } print.info("\n"); } catch (SocketTimeoutException timeoutException) { // 서버와 연결이 안 좋을 때 hang 방지 ErrorHandling.saveErrLog(String.format("%s_%03d", subFolder, pageNum), "마루마루 서버와의 연결이 원활하지 않습니다.", timeoutException); } catch (Exception e) { //다운로드 중 에러 발생시 에러 로그를 txt형태로 저장 ErrorHandling.saveErrLog(String.format("%s_%03d", subFolder, pageNum), "", e); } } /** * 내부적으로 이용되는 download 메서드<br> * String 형식의 이미지 주소를 받아서 다운로드<br> * <i>추후 HttpComponent로 변경 예정</i> * * @return 다운로드한 이미지의 byte size * @throws Exception */ private int download() throws Exception { HttpURLConnection conn = (HttpURLConnection) new URL(imgURL).openConnection(); conn.setRequestMethod("GET"); conn.setConnectTimeout(MAX_WAIT_TIME); // init 연결 타임아웃 conn.setConnectTimeout(MAX_WAIT_TIME << 1); // data read 타임아웃 conn.setRequestProperty("charset", "utf-8"); conn.setRequestProperty("User-Agent", UserAgent.getUserAgent()); //conn.setRequestProperty("Accept-Encoding", "gzip"); conn.setRequestProperty("Host", host); conn.setRequestProperty("Referer", referer); int imageSize = conn.getContentLength(); // byte size InputStream inputStream = conn.getInputStream(); //속도저하의 원인 String savePath = String.format("%s%03d%s", path, pageNum, getExt(imgURL, ".jpg")); BufferedInputStream bis = new BufferedInputStream(inputStream, BUF_SIZE); BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(savePath), BUF_SIZE); IOUtils.copyLarge(bis, bos); bos.close(); bis.close(); return imageSize; } @Override public String toString() { return String.format("ImageURL: %s, SubFolder: %s, PageNumber: %d", imgURL, subFolder, pageNum); } } }