본문 바로가기
Back-end/JAVA

[크롤링] Jsoup을 이용한 JAVA 크롤러 (2) - 파일 다운로드

by 허도치 2020. 2. 25.

2020/02/25 - [Back-end/JAVA] - [크롤링] Jsoup을 이용한 JAVA 크롤러 (1) - HTML 파싱

 

0. 서론

 지난 포스트에서 웹페이지를 크롤링하여 File명과 File의 다운로드 URL을 수집해보았다. 이렇게 간단하게 텍스트를 수집하는 정도의 크롤러를 구현한다면 이전 포스트만으로도 충분히 구현할 수 있을 것이라고 생각한다. 하지만, 데이터뿐만 아니라 파일을 다운로드하여 보관하고 싶을 수도 있다. 그래서 이번 포스트에서는 다운로드 URL을 가지고 실제로 파일을 다운로드하는 방법을 다루어볼 계획이다.

 이번에 만든 예제를 나중에 재사용하려고 Class를 나누어서 작성하였다. 다소 복잡해보일 수 있겠지만 Class를 나누어서 관리하는게 더 깔끔하다. 그리고, File을 Download하려면 Thread를 사용해야한다. Thread에 대해서 모른다면 예제를 통해 사용법을 익히는 것을 추천한다.
 

 

 

 

1. 프로젝트 준비
1-1. 프로젝트 구성

1) App.java
   : Crawler를 실행하는 Main Class.

2) Crawler.java
   : Jsoup을 이용하여 만든 Crawler Class.
   : Download를 위해 Thread가 사용됨.

3) Const.java
   : Crawler를 설정하는데 필요한 기본값을 선언해둔 Class.
   ; 별도의 기능이 없고 초기값을 static으로 선언.

 

 

 

2. Const Class
2-1. 스크립트 작성
1
2
3
4
5
6
7
8
9
10
// Const.java
package crawler.utils;
 
public class Const {
    public final static int DEFAULT_THREADS = 2;
    public final static String DEFAULT_DOWNSLOADS = "C:/downloads/";
    public final static String DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.116 Safari/537.36";
    public final static String DEFAULT_CONTENT_TYPE = "application/json;charset=UTF-8";
    public final static int DEFAULT_TIMEOUT = 5*1000;
}
cs

1) DEFAULT_THREADS
   : 스레드 수

2) DEFAULT_DOWNLOADS
   : 다운로드된 파일을 저장할 폴더

3) DEFAULT_USER_AGENT
   : Request를 던질 때, 함께 전달하는 사용자의 정보.
   : Chrome > 개발자도구 > Console에서 [ navigator.navigator.userAgent ]를 입력하면 현재 사용하고 있는 User-Agent 정보를 알 수 있음.

4) DEFAULT_TIMEOUT
   : Response을 기다리는 시간.

 

 

 

3. Crawler Class
3-1. 스크립트 작성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
// Crawler.java
package crawler.utils;
 
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
 
import org.jsoup.Connection;
import org.jsoup.Connection.Response;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
 
public class Crawler {
    private static ExecutorService executorService = null;
    private static Connection conn = null;
    private static Properties options = null;
 
    // 생성자()
    public Crawler() {
        // 1. Thread Pool 설정
        executorService = Executors.newFixedThreadPool( Const.DEFAULT_THREADS );
    }
    // 생성자( URL )
    public Crawler(String URL) {
        // 1. Thread Pool 설정
        executorService = Executors.newFixedThreadPool( Const.DEFAULT_THREADS );
        
        conn = Jsoup.connect(URL);
    }
    // 생성자( URL, props );
    public Crawler(String URL, Properties options) throws Exception {
        // 1. Thread Pool 설정
        executorService = Executors.newFixedThreadPool( Const.DEFAULT_THREADS );
 
        conn = Jsoup.connect(URL);
        setOptions(options);
    }
 
    @SuppressWarnings("unchecked")
    public void setOptions(Properties _options) throws Exception {
        options = _options;
        
        // Download 폴더 설정, 경로에 파일이 없으면 생성
        final String downloads = options.getProperty("downloads", Const.DEFAULT_DOWNSLOADS);
        File file = new File( downloads );
        if!file.exists() || !file.isDirectory() ) {
            file.mkdirs();
        }
        
        if( conn == null ) return;
        
        // Headers 설정
        if( _options.get("headers"!= null ) {
            conn.headers((Map<String,String>)_options.get("headers"));
        }
        // Header: Content-Type 설정
        conn.header("Content-Type", (String)_options.getProperty("Content-Type", Const.DEFAULT_CONTENT_TYPE));
 
        // User-Agent 설정
        conn.userAgent((String)_options.getProperty("User-Agent", Const.DEFAULT_USER_AGENT));
 
        // Connection Timeout 설정
        int timeout = Const.DEFAULT_TIMEOUT;
        if( _options.get("timeout"!= null ) {
            timeout = (Integer) _options.get("timeout");
        }
        conn.timeout(timeout);
    }
 
    // GET
    public Document get() throws IOException {
        return conn.get();
    }
    // GET with DATA
    public Document get(Map<StringString> data) throws IOException {
        return conn.data(data).get();
    }
 
    // POST
    public Document post() throws IOException {
        return conn.post();
    }
    // POST with DATA
    public Document post(Map<StringString> data) throws IOException {
        return conn.data(data).post();
    }
 
    // Download File
    public Runnable download(final String URL, final String filename) {
        // Thread(Runnable) 객체 생성
        Runnable runnable = new Runnable() {
            public void run() {
                // 파일을 저장할 경로
                String downloads = options.getProperty("downloads", Const.DEFAULT_DOWNSLOADS);
 
                // 현재 Thread의 이름
                String threadName = Thread.currentThread().getName();
 
                // Jsoup Connection 객체 생성, Content-Type의 종류 무시
                Connection conn = Jsoup.connect(URL).ignoreContentType(true);
 
                // 저장할 File 객체 생성
                File saveFile = null;
 
                // File에 데이터를 작성할 Stream 객체 생성
                FileOutputStream out = null;
 
                try {
                    System.out.println"["+threadName+"][START] "+ URL );
 
                    // Request의 결과 객체
                    Response response = conn.execute();
 
                    // 요청한 페이지의 Content-Type
                    String contentType = response.contentType();
                    System.out.println"["+threadName+"][Content-Type] "+ contentType );
 
                    saveFile = new File(downloads, filename);
 
                    out = new FileOutputStream(saveFile);
                    out.write( response.bodyAsBytes() );
                    out.close();
 
                } catch (IOException e) {
                    e.printStackTrace();
                } finally {
                    // 저장된 File 확인
                    if( saveFile.exists() ) {
                        System.out.println"["+threadName+"][SAVED] "+saveFile.getPath() );
                    } else {
                        System.out.println"["+threadName+"][SAVE FAILED] "+saveFile.getPath() );
                    }
                }
            }
        };
 
        executorService.execute(runnable);
 
        // executorService.submit(runnable);
        // execute: Runnable을 인자로 받으며 반환값이 없음, void.
        // submit: Runnable과 Callable을 인자로 받으며 반환값을 받을 수 있음, return Future.
 
        return runnable;
    }
 
    // Download Files
    public void downloads(List<Map<String,String>> download_list) {
        for(Map<StringString> download_info : download_list ) {
            String URL = download_info.get("URL");
            String filename = download_info.get("filename");
 
            // 1. Download Thread 실행
            download(URL, filename);
        }
 
        // 2. Thread Pool 종료
        executorService.shutdown(); // Task Queue에 남아있는 Thread와 실행중인 Thread 처리된 뒤 종료
 
        try {
            // 3. 5분 후에도 종료가 되지 않으면 강제 종료
            if!executorService.awaitTermination(5, TimeUnit.MINUTES) ) {
                executorService.shutdownNow();
            }
            executorService = null;
        } catch (Exception e) {
            executorService.shutdownNow();
            executorService = null;
        } finally {
            if( executorService != null ) {
                executorService.shutdownNow();
            }
        }
    }
}
cs

1) [46~73 ln] - Crawler 설정 함수
   : Request 옵션을 설정하는 함수
   : 함수에 적용해둔 옵션들 외에도 다양하게 있는데, 필요한 것만 우선 적용하였음.

2) [76~82, 85~91 ln] - Request 함수
   : GET method로 Request를 던지는 함수
   : Java의 장점인 다형성을 이용하여 매개변수가 다른 2개의 함수를 생성.

3) [94~149 ln] - 개별 File Download 함수
   : 파일을 개별로 다운로드.
   : Thread로 구성해서 복잡해 보일 수도 있지만, 핵심만 보면 됨.
   : [105 ln] - ignoreContentType(true)는 Response의 Content-Type을 무시하는 옵션으로 이 옵션을 true로 설정해야 다운로드 받을 파일의 타입에 상관없이 받을 수 있음.
   : [123~127 ln] - 요청결과를 실제 파일로 저장하는 부분.

4) [152~178 ln] - 전체 File Download 함수
   : 리스트에서 다운로드 정보를 읽어서 개별 다운로드 함수를 호출.

 

 

 

4. App Class - Main Class
4-1. 스크립트 작성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
// App.java
package crawler;
 
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
 
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
 
import crawler.utils.Crawler;
 
public class App {
    public static void main(String args[]){
        try {
            run("https://heodolf.tistory.com/18");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
 
    // Crawler 실행
    public static void run(String URL) throws Exception {
 
        // 1. Crawler 옵션 설정
        Properties options = new Properties();
        options.put("Content-Type""application/html;charset=UTF-8");
        options.put("downloads""C:/work/crawler/downloads/");
        options.put("timeout"30*1000);
 
        // 2. Crawler 생성
        Crawler crawler = new Crawler(URL, options);
 
        // 3. HTML 파싱
        Document html = crawler.get();
 
        // 4. <a> 태그 추출.
        Elements files = html.select(".fileblock a");
 
        // 5. File Download 정보 추출
        List<Map<StringString>> download_list = new ArrayList<Map<StringString>>();
        Map<StringString> download_info = null;
        for(Element file : files) {
            download_info = new HashMap<StringString>();
 
            String href = file.attr("href");
            String filename = file.text();
 
            if( filename != null ) {
                filename = filename.split(" ")[0];
            }
 
            download_info.put("filename", filename);
            download_info.put("URL", href);
 
            download_list.add( download_info );
 
            // Download File
            //crawler.download( href, filename );
        }
 
        // 6. Download Files
        crawler.downloads( download_list );
    }
}
cs

1) [29~32 ln] - Crawler 옵션 설정
   : Const Class에서 기본값으로 설정했기 때문에 설정하지 않아도 상관 없음.

2) [44~63 ln] - 웹페이지에서 File Download 정보 추출
   : 지난 포스트에서 추출한 정보를 List 객체에 저장
   : [62 ln] - List에 저장하지 않고 개별적으로 다운로드를 요청할 수 있음.

 

4-2. 실행

1) IDE를 사용하는 경우
   - App.java를 Java Application 실행

2) 콘솔을 사용하는 경우
   $ javac -classpath %CLASSPATH% -sourcepath ./crawler ./crawler/utils/Crawler.java
   $ javac -classpath %CLASSPATH% -sourcepath ./crawler ./crawler/utils/Const.java
   $ javac -classpath %CLASSPATH% -sourcepath ./crawler ./crawler/App.java
   $ java -classpath %CLASSPATH% crawler.App

 

4-3. 실행 결과

1) 각 java 파일을 컴파일한 후 실행.

2) 실행 결과를 보면 thread-1, thread-2 가 번갈아가면서 로그가 출력되는 것을 확인할 수 있음.

3) 파일이 정상적으로 다운로드된 것을 확인할 수 있음.

 

 

 

마치며

- 간단하게 파일 다운로드 부분만 작성하려고 했었는데, 나중에도 써먹을 생각을 하며 만들었더니 많이 복잡해보인다. 하지만, 하나하나 뜯어보면 어려운 로직이 아니기 때문에 쉽게 이해할 수 있을 것이라고 생각한다.
- Jsoup의 아쉬운 점이 동적 웹페이지를 크롤링 할 수 없다는 점인데, 이는 Python 에서 크롤링 할 때와 마찬가지로 Selenium을 사용하면 된다고 한다. Java에서 Selenium을 사용하는 방법에 대해서 알아보고 다음 포스트에서 다루어보도록 하겠다.

댓글