본문 바로가기

Programming/Spring

[Selenium + Headless Chromium] Java로 인스타그램 대용량 크롤링하기 2 - 배치 프로그램

반응형

 

 

 

selenium

 

이번 포스팅에서는 제가 Spring framework 5에서 Java selenium 라이브러리를 사용했던 방법을 1편에 이어 소개해드리겠습니다. 제 개발 환경이나 프로젝트에 관한 정보는 1편에 공개되어있으며, 이번에는 배치프로그램 내에서 수행한다는 특성을 많이 살려보도록 하겠습니다.

 

이번에는 전반적인 DB 구성과 크롤링 코드를 수행할 배치 프로그램 소스코드도 간단히 소개하면서 포스팅을 진행해보겠습니다. 제가 구현한 배치프로그램에 관심이 생기신다면, 'Spring' 카테고리 내에 더 많은 글이 있으니 방문해주셨음 합니다 ㅎㅎ

 

 

Instagram : 장소태그 크롤링하기

 

 2편의 첫 주제인 만큼 기본적인 내용을 조금 다뤄보려 합니다.

 

 경험상, selenium에서 크롤링을 위해 가장 많이 쓰게 되는 요소는 xpath와 cssSelector이지만 최근 크롬 브라우저에서 full Xpath 라는 요소를 추가하였습니다. 사실 크롤링에서는 xpath와 full xpath의 차이를 크게 느끼지는 못하였으나, 저는 한번에 많은 페이지를 크롤링하다보면 xpath가 간혹 잡히지 않았기에 try-catch 로 둘 다를 순차적으로 잡을 수 있게끔 구현했습니다. 테스트 결과, xpath 보다는 full xpath가 좀 더 예외에 걸리지 않고 안정적으로 돌아갔기에 저는 full xpath를 우선적으로 사용하겠습니다. 포스팅에서 언급하는 xpath는 full xpath를 의미함을 알려드립니다. 어쨌든 이렇게 했더니 나름 예외없이 잘 먹히더군요 ㅎㅎ 

 

 장소태그는 인스타그램의 웹에서 아래 사진과 같은 위치에 있습니다. 해당 부분을 개발자모드에서 찍고 xpath나 css selector를 복사해서 사용하시면 됩니다. 아래 사진의 오른쪽 부분을 보시면, 사용자가 추가한 장소 태그는 instagram.com/explore/locations/... 으로 url이 걸려있으며 element.getText() 로 낼 수 있는 결과는 '와우신내떡' 임을 알 수 있습니다. 이 url을 저장해 놓으면 나중에 '와우신내떡' 이라는 장소태그를 건 게시글들을 조회해 볼 수 있겠습니다.

 

 

하지만 인스타그램에서 장소태그는 필수적으로 추가해야 하는 요소가 아닙니다. 즉, 게시글에 따라 장소태그가 없을 수도 있다는 뜻이죠. 그러므로 여기서 발생할 수 있는 NoSuchElementException에 대한 부분은 코드 단에서 처리해주도록 합시다. 이 뿐 아니라 NoSuchElementException은 제가 지정한 cssSelector를 크롤러가 못찾아갔을 때도 발생할 수 있습니다. 따라서 catch로 locator를 변경해주면서 다시 한번 잡아봐야겠습니다.

 

두번째로는 WebDriverWait에서 발생할 수 있는 TimeoutException을 처리해주고자 합니다. 

 

위에서도 언급했듯, 장소태그는 게시글에서 있어도 되고 없어도 되는 요소이므로 장소태그의 유무를 철저하게 검사하는 시간을 들일 가치는 없어보였습니다. 애초에 없을 수도 있는 요소인데 하나하나 검사하다가 배치 프로그램의 전체적인 시간을 delay 시킬 수도 있기 때문입니다. 그래서 저는 NoSuchElementException과 TimeoutException을 묶어 한번에 처리하였습니다. 

 

만약 게시글에 장소 태그가 없다면 ""를, 크롤러가 2번의 기회동안 장소태그를 찾아가지 못했더라도 ""를 리턴합니다.

 

@Override
    public String getPlacetag(WebDriver driver) {
        this.driver = driver;
        String placetag = "";
        By placeElement;

        try {
            placeElement = By.cssSelector("body > div._2dDPU.vCf6V > div.zZYga > div > article > header > div.o-MQd.z8cbW > div.M30cS > div.JF9hh > a");
            webDriverWait.until(ExpectedConditions.elementToBeClickable(driver.findElement(placeElement)));

            placetag = driver.findElement(placeElement).getText();

        } catch(NoSuchElementException
                | TimeoutException one_more_try) {

            try {

                placeElement = By.xpath("/html/body/div[3]/div[2]/div/article/header/div[2]/div[2]/div[2]/a");
                webDriverWait.until(ExpectedConditions.elementToBeClickable(driver.findElement(placeElement)));
                placetag = driver.findElement(placeElement).getText();

            } catch(Exception e) {
                placetag = "";
            }

        } catch(NullPointerException e) {
            placetag = "";
        }

        return placetag;
    }

 

인스타그램의 게시글은 서로 다른 url을 가지고 있지만, 태그로 검색한 결과를 보일 때, 마치 원페이지로 구성한 것 마냥 같은 url 내에서 <다음> 버튼을 클릭하며 모든 게시글을 방문해 볼 수 있습니다. 이 때 위 코드로 약 10000개가 넘는 게시글도 연속적으로, 안정적으로 장소태그를 얻어낼 수 있겠습니다. 즉, 로드된 페이지 내에서 이 메소드를 10000번 수행한다는 조건으로,

 

1. 크롤러가 갑자기 css Selector를 제대로 찾아가지 못했을 때

2. 다음 게시글로 이동하는 버튼을 눌렀는데 부하가 생겨 TimeoutException 이 났을 때

3. 애초에 장소태그가 없는 게시글이었을 때

4. 이미 web element는 로드된 상황이었는데 WebDriverWait가 또 걸렸을 때

5. web driver가 로드된 후 너무 시간이 오래 지나, 자동으로 quit() 되었을 때 (NPE)

 

 이렇게 5가지의 예외상황을 처리한 것입니다. 간단한 크롤링이었다면 3줄로 뚝딱이었을 코드인데 말이죠...

 

간단한 DataBase 구조

 

해시태그와 게시글 본문을 크롤링하는 법을 소개하기 전에, 크롤링한 결과가 저장될 데이터베이스 구조를 정말 간단하게 가정해보겠습니다. 

 

DB 구상

 

 description을 크롤링하는 코드는 1편의 Date 를 얻어내는 코드와 유사하여 따로 소개하지 않겠습니다. 데이터베이스에서 subway_station에 저장된 station name은 다른 모든 테이블의 외래키로서 작용하겠습니다. subway_station 테이블에 저장된 지하철 역 이름으로 인스타그램에 검색을 하여(https://www.instagram.com/explore/tags/지하철역이름/) 결과로 나온 최신 게시글들 3개월 치 분량을 크롤링합니다. 먼저 각 게시글 사진의 description을 크롤링하여 description에 outdoor가 들어가있으면 Instagram_places 테이블로, food가 들어가있으면 Instagram_food 테이블로 추가 정보가 저장됩니다.

 

이같은 구조를 먼저 언급하는 이유는 content와 hashtags, restaurant, photo url을 크롤링 할 방식이 앞선 Date와 장소태그와는 조금 다르기 때문입니다. 이 4가지 경우는 크롤링 작업에 있어서 연산이 필요한데, 이럴 경우에는 먼저 description이 일치한다면 각 게시글의 url을 받아두고 시간이 오래 걸리는 일은 나중에 처리하겠습니다. 따라서 사용하는 xpath나 css Selector의 경로가 앞선 요소들과는 조금 다를 것입니다. 

 

 

크롤링을 수행하는 Batch Job

 

배치 프로그램 내에서 만나는 모든 인스타그램의 게시글 내용과 해시태그를 크롤링한다면 시간이 어마어마하게 딜레이 될 것입니다. 따라서 우리는 Instagram_places 테이블에 들어갈 자격이 있는 게시글만 url을 받아와서, 나중에 따로 모아놓고 연산 + 크롤링을 시도하도록 합시다. 배치프로그램 내에서 수행하는 특징이 크롤링 자체에도 영향을 주고 있으므로, 먼저 제가 구현한 배치프로그램을 정말 간단히 보겠습니다.

 

Batch Program's Job : To Set Up Instagram_places Table

 

 

searchLocation 이라는 이름으로 등록된 JOB에서 관할하는 테이블은 위의 Instagram_places 테이블과 굉장히 유사합니다. 이 Job은 3개의 Step으로 이루어져 있습니다.

 

Step - 1 : DB에 'station_name' 컬럼 필드들을 뒤져서 현재 read()로 읽어온 지하철 역과 일치하는 row가 있는지를 검사합니다. 만약 있다면, 마지막으로 크롤링된 날짜를 검사하고 그 이후부터만 크롤링하기 위함이겠습니다. 하지만 지금은 생전 처음 배치프로그램을 돌린다고 생각하고 코드를 구현합니다. 

Step - 2 : 3개월 치 게시글의 인근 지하철역, 날짜, 좋아요 수, 사진 description, 게시글내용, 해시태그를 크롤링하여 DB insert 작업을 합니다.

Step - 3 : step 2 를 진행하다가 description에 food가 있는 경우 Instagram_food 테이블에 지하철역, 날짜, 좋아요 수, AWS S3에서 받은 url, 게시글에 포함된 음식점 이름들을 저장합니다.

 

여기서 Step 2 를 구현하는 방식을 살펴보겠습니다. 

 

 

Step2는 reader - processor - writer 의 흐름으로 진행되며, writer가 직접 insert 할 때 쓰이는 DataSource에 등록된 transactionManager 를 Step 정의부에서 넣어주었습니다. 

 

@JobScope 

Step 선언문에서 쓰입니다. 그러면서, Spring Batch가 스프링 컨테이너를 통해 지정된 Job의 실행시점에 메소드를 Bean으로 생성하는 역할을 합니다.
즉, Bean의 생성 시점을 지정된 Scope가 실행되는 시점으로 지연시킵니다. 

이렇게 함으로서 JobParameter가 있는 경우는 JobParameter를 Application이 최초 구동되는 시점이 아닌 배치프로그램이 시작되는 시점에 바인딩할 수 있겠습니다. 이걸 Late Binding이라고 합니다. 

 

Overall

subway_station 테이블과 같은 역할을 합니다. Overall 테이블은 지하철역이름, 해당 역에 위치한 음식점(500여개) 등의 정보를 담고 있습니다. 비정형 데이터를 각자의 성격에 맞는 테이블에 분배하기 위한 키 역할을 한다고 해도 무방합니다.

 

<Overall, List<Instaplace>>

reader에서 내보내는 데이터 타입은 Overall 이며, processor는 Overall 형식의 데이터를 파라미터로 받아 로직을 수행합니다.

processor에서 내보내는 데이터 타입은 List<Instaplace> 이며, writer는 List<? extends List<Instaplace>> 형식의 데이터를 받아 write()를 수행합니다. 이 때 List 의 크기는 아래 언급되는 CHUNK_SIZE 가 됩니다. 또, 만약 processor에서 null을 출력한다면 해당 List<Instaplace> 는 데이터베이스에 write() 되지 않습니다. 따라서 로직만 수행하고 싶은 경우에는 null을 출력하도록 구현하시면 되겠죠.

 

chunk

Step 이 reader - processor - writer 의 구조로 짜여있을 때, 

reader 한 번에 하나씩 데이터를 읽어 -> processor 한 번에 하나씩 데이터를 처리해서 -> chunk라는 덩어리를, 지정한 CHUNK_SIZE 만한 크기가 될 때까지 만들어서 -> writer chunk 단위로 트랜젝션을 다루어 DB에 커밋시킵니다. 배치프로그램을 다루는 포스팅에서 더 자세히 다루겠습니다. 

여기서는, Chunk 단위로 트랜젝션이 수행되는 만큼, 롤백도 chunk 만큼만, 커밋도 chunk 만큼만 이루어진다는 사실만 알면 되겠습니다. 

 

Batch program - setupInstaLocationStep

 

ItemReader

overall 테이블에서 하나씩 데이터를 읽어옵니다. station과 restaurants 를 읽어오지만, restaurants의 경우는 step-3에서 쓰이므로 일단은 station에 대해서만 생각해보도록 합니다.

JdbcCursorItemReader를 사용하므로 데이터는 streaming으로 처리되며 쿼리의 분할 없이 처리됩니다. 하지만 내부적으로는 Database에서 fetchSize 만큼씩만 데이터를 가져와, read()를 통해 하나씩 읽어오는 과정을 채택합니다. 쿼리가 분할없이 처리된다는 말에서 알 수 있듯, Cursor는 하나의 DB Connection으로 해당 배치가 끝날 때 까지 사용하게 됩니다. 따라서 Step의 수행시간이 길다면 Cursor보다는 Paging 방식을 사용하는 것이 좋겠습니다.

 

ItemProcessor

process() 에서 web driver 를 로드시키고 크롤링을 수행하는 과정을 구현하겠습니다. 크롤링된 결과물을 한 게시물 당 Instaplace 객체 하나에 저장될 것입니다. 같은 지하철 역을 가진 게시물이 2개 이상 있을 수 있으므로 List<Instaplace> 를 processor 단위에서 생성하여 writer로 보냅니다. 여기서는 ItemProcessor 코드에 대해서만 살펴보겠습니다.

 

ItemWriter

processor 에서 받은 List<Instaplace> 를 데이터베이스에 insert 하거나 update 합니다. 또, 만약 3개월이 지난 예전 데이터가 데이터베이스에 남아있다면 지워줍니다.

 

 

ItemProcessor : 크롤링 수행 단계

 

ItemProcessor 선언부

 

전반적인 구조를 보여드리기 위해 선언부를 먼저 보이겠습니다. processor 클래스는 custom 하게 구현하기 위해 클래스로 만들었습니다. 

제네릭 타입으로 선언된 CrawlingDelegator 클래스는 게시글 내용과 해시태그를 연산과 함께 크롤링하여 수행하는 슈퍼클래스이며, processor에서 이걸 상속받고 있습니다. Instaplace 타입으로 저장될 게시글 내용과 해시태그를 반환받기 위함이죠. 

 

모든 크롤링 소스코드는 InstaCrawlImpl 클래스에서 담당하고 있으며, Step들끼리 공유가 필요한 변수들은 LocationStepsDataShareBean에서 별도로 관리하게끔 하였습니다. 

 

process() 는 다음과 같은 과정으로 진행됩니다.

 

    @Override
    public List<Instaplace> process(Overall overall) throws Exception {
        logger.info("[SearchLocationJob] : SetupInstaLocation-ItemProcessor started.");

        List<Instaplace> objectList = new ArrayList<>();
        Instaplace object;
        String station = "";
        int chance = 0;
        ZoneId zid = ZoneId.of("Asia/Seoul");
        Date limitDate;
        int index = 1;
        Date uploadDate = null;
        station = overall.getStation();
        Map<Instaplace, String> pagelinks = new HashMap<>();
        Map<String, Date> latestCrawlDate = this.dataShareBean.getLatestDatePerStation();
        Date today = Date.valueOf(ZonedDateTime.now().withZoneSameInstant(zid).toLocalDate());

        this.dataShareBean.putRestaurantsPerStation(station, overall.getRestaurantsOfJson());

        if(latestCrawlDate.size() == 0 || latestCrawlDate.get(station) == null) {
            limitDate = Date.valueOf(ZonedDateTime
                                    .now()
                                    .withZoneSameInstant(zid)
                                    .minusMonths(3)
                                    .toLocalDate());
        }
        else {
            limitDate = latestCrawlDate.get(station);
        }

        this.webDriver = getReadyForCrawling(station);
        if(this.webDriver == null) {
            return null;
        }

        while(true) {

            if(index == 1) {
                index = this.instaCrawlImpl.waitPage(this.webDriver, 1, 9); //4
            }
            else {
                // if last page
                if (instaCrawlImpl.waitPage(webDriver, 2, 0) == 3) { // 다음 버튼 클릭
                    break;
                }
            }

            uploadDate = instaCrawlImpl.getDate(this.webDriver);

            if(uploadDate != null && uploadDate.equals(limitDate)) {

                if(chance < 5) {
                    chance ++;
                    continue;
                }
                else if(chance == 5) {
                    break;
                }

            } else if(uploadDate != null && uploadDate.before(limitDate)) {

                if(uploadDate.toString().equals("1970-01-01")) {
                    continue;
                }
                else if(chance < 10) {
                    chance ++;
                    continue;
                }
                else if(chance == 10) {
                    break;
                }

            } else if(uploadDate != null && uploadDate.equals(today)) {
                continue;
            }

            chance = 0;
            object = new Instaplace();

            object.setStation(station);
            object.setDate(uploadDate);
            object.setLikeCNT(this.instaCrawlImpl.getLikeCNT(this.webDriver));


            pagelinks.put(object, instaCrawlImpl.getPhotopageURL(this.webDriver));
        }

        super.setDataShareBean(this.dataShareBean);
        objectList = super.setLocationPostContent(this.instaCrawlImpl, this.webDriver, pagelinks);

        return objectList;
    }

 

인스타그램이 제공하는 <최신 순 보기>의 기능이 다소 정확성이 떨어져서, 간혹 아주 예전에 업로드된 글이 연속적으로 섞여있는 경우가 많았습니다. 이 부분에 대해 고민해봤지만, 과거의 글을 모두 skip 하기에는 정말 크롤링을 stop 해야할 기준 날짜나 그 이전의 날짜가 제대로 기준점의 역할을 못 하게 될 것 같았습니다. 따라서 조금 단순무식한 방법같지만, 그냥 10번의 기회를 부여하였고 과거에 해당하는 날짜가 나올 경우에는 별도의 크롤링을 수행하지 않았습니다.

 

크롤링은 while 문 내에서 이루어집니다. 미리 셋업한 web driver를 파라미터에 주입시키고, 한 번의 loop 가 수행되면 다음 글로 향하는 버튼을 클릭하게끔 했습니다. 그리고 loop가 한 번 수행될 때 마다 정상적인 글이었다면 url을 수집하게끔 하였습니다.

 

이렇게 수집된 url은 map 형식으로 객체와 함께 저장되며, 슈퍼클래스의 메소드를 호출할 때 파라미터로 넣어줍니다. 

 

CrawlingDelegator.setLocationPostContent() 일부

 

CrawlingDelegator.setLocationPostContent()

 

위와 같은 로직으로, 따로 받은 url 을 게시글마다 따로 접속하여 연산을 수행하도록 하였습니다. 연산은 크롤링 코드와 함께 구현되어 있으니, 이제 여기서 수행되는 크롤링 코드를 소개하며 어떤 연산을 수행하는지 알아보겠습니다. 

 

로직 내에서, setIsFoodPost 를 1로 설정함으로서, 만약 description 에 food가 포함되어 있다면 멤버변수인 photoUrl이 AWS S3에 업로드된 음식 이미지의 url을 받아오도록 하였습니다. 만약 description에 food가 포함되어있지 않는다면 photo는 크롤링되지도 않으며, 음식점 정보와 사진을 수집하기 위한 extractFoodContent 메소드를 수행하지도 않습니다. 

 

위 코드에서 crawl.getPost(driver)crawl.getHashtags(driver), crawl.findHashtags(content, hashtags) 를 설명하겠습니다.

 

Instagram : 게시글 본문 크롤링하기

 

post는 개발자모드가 제공하고 있는 full xpath와 xpath를 순차적으로 검사하도록 하였습니다. 역시 Thread.sleep() 대신 크롤링될 locator가 로드되면 바로 크롤링을 수행하도록 구현하였습니다.

 

코드는 다음과 같습니다. 여기서 처리한 예외는 위의 장소태그가 수행한 예외처리와 상당히 유사합니다.

 

    @Override
    public String getPost(WebDriver driver) {
        this.driver = driver;
        String post = "";
        By locator;

        try {

            locator = By.xpath("/html/body/span/section/main/div/div/article/div[2]/div[1]/ul/div/li/div/div/div[2]/span");
            webDriverWait.until(ExpectedConditions.visibilityOfElementLocated(locator));

            this.element = driver.findElement(locator);
            post = element.getText();

        } catch (NoSuchElementException
                | TimeoutException one_more_try) {

            logger.info("Has Exception in post crawling.");

            try {
                Thread.sleep(10000);
                this.element
                        = driver.findElement(By.xpath("//*[@id=\"react-root\"]/section/main/div/div/article/div[2]/div[1]/ul/div/li/div/div/div[2]/span"));
                post = element.getText();

            } catch (NoSuchElementException no_post_exist) {

                post = "";
            }

        } catch (NullPointerException e) {
            logger.error("Cannot load your web Driver.");
            post = "";
            
        } finally {
            return post;
        }
    }

 

중간에 Thread.sleep() 을 수행하며 예외를 catch 하려 시도한 이유는, 처음으로 걸린 TimeoutException이, 이미 locator가 다 로드 되었는데 WebDriverWait를 실행했기 때문에 발생했을 수도 있었기 때문입니다. 

이럴 가능성이 있는 이유는, CrawlingDelegator.setLocationPostContent()에서 crawl.getPhotoUrl() 으로 이미 webDriverWait() 를 한 번 이상 수행하고 getPost()를 수행하기 때문입니다. 이미 게시글의 photo src를 얻어올 때 까지 기다렸다가 S3에 업로드까지 끝낸 상황에서 getPost() 에 도달한다면, post의 locator는 로드를 끝내버린 상황일 수도 있습니다. 하지만 음식 사진이 아니었을 경우에는 webDriverwait를 별도로 주어야겠지요.

 

 

Instagram : 해시태그 크롤링하기

 

바로 인스타그램에서의 해시태그는 몇가지 특성을 가지고 있습니다. 따라서 해시태그를 잡아보기 전에 몇 가지 생각해 볼 점들이 있습니다.

 

1. 해시태그의 형식

 -> #해시태그 #이렇게 #걸수있음

 -> #그런데#이렇게도#걸더라

 -> #이것도

      #있던데

      #한줄띄워서

      #하던데

 

2. 해시태그의 위치

 해시태그는 본문이나 댓글창, 대댓글창에서 입력할 수 있으며, 인스타그램이 제공하는 검색결과에서의 차이는 없습니다.

 

3. 누가 건 해시태그?

 댓글에 입력된 해시태그의 경우, 그 주인은 글쓴이가 될 수도, 댓글쓴 사람이 될 수도 있습니다.

 

 저는 1번과 2번의 경우를 전부 처리해주고자 하였으며, 3번의 경우는 게시글을 쓴 사람이 입력한 해시태그만을 데이터베이스에 저장하고자 했습니다. 아무래도 해시태그 특성상 어떤 내용을 입력해도 태그로 사용이 가능하니, 글쓴이가 아닌 사람이 태그를 걸었을 경우 게시글과의 연관성을 보장할 수 없다고 생각했기 때문입니다. 

 

그럼, 먼저 위에서 긁어온 게시글 본문에서 해시태그가 있는지 검사해보겠습니다. 위의 세 가지의 경우를 모두 고려해보아야겠습니다.

 

    @Override
    public List<String> findHashtags(String content, List<String> hashtags) {
        String temp = content;

        for (int character=0; character<temp.length(); character++) {
        
            if (temp.charAt(character) == '#') {
                temp = temp.substring(character);
                character = 0;

                space = findNearestSpace(temp, character);

                if (space == 2201)
                    hashtag = temp;
                else
                    hashtag = temp.substring(0, space);

                hashtags.add(hashtag);
            }
            
        }
        return hashtags;
    }


    private int findNearestSpace(String temp, int character) {
        int minimum;
        int empty = temp.indexOf(' ');
        int newLine = temp.indexOf('\n');
        int newTag = (temp.substring(character+1)).indexOf('#') + 1;

        if(empty == -1)
            empty = 2201;
        if (newLine == -1)
            newLine = 2201;
        if (newTag == 0)
            newTag = 2201;

        minimum = (empty < newLine) ? empty : newLine;
        minimum = (minimum < newTag) ? minimum : newTag;

        return minimum;
    }

 

 위에서 제시한 해시태그의 세 가지 형식은 findNearestSpace에서 본격적으로 검사합니다. 

 

 인스타그램의 게시물 최대 글자 수는 2200 입니다. 따라서 만약,

 '#해시태그'를 끝으로 본문을 마쳤을 때, findNearestSpace() 메소드는 2201을 리턴하여, 본문의 마지막 해시태그까지 찾아낼 수 있도록 했습니다. 

 이렇게 찾아낸 해시태그는 List<String> 형식으로 반환됩니다. 해시태그는 본문 뿐만 아니라 댓글, 대댓글에서 추가적으로 찾아낼 수 있으므로 배열이 아닌, 사이즈 변경이 용이한 리스트 형식을 리턴타입으로 잡았습니다.

 

 

이번에는 댓글이나 대댓글에 글쓴이가 입력한 해시태그가 있는 지를 검사하고, 있다면 이미 존재하는 List<String> 에 추가적으로 저장해보겠습니다.

 

    @Override
    public List<String> getHashtags(WebDriver driver) throws InterruptedException {
        WebDriverWait webDriverWait = new WebDriverWait(driver, 20);
        hashtags = new ArrayList<>();

        this.driver = driver;
        this.findWebElement = By.xpath("//*[@id=\"react-root\"]/section/main/div/div/article/header/div[2]/div[1]/div[1]/h2/a");

        try {
            webDriverWait.until(ExpectedConditions.visibilityOfElementLocated(findWebElement));
        } catch (TimeoutException
                | NoSuchElementException e) {

            Thread.sleep(5000);
        }

        try {
            this.element = driver.findElement(findWebElement);
        } catch(java.util.NoSuchElementException e) {
            return hashtags;
        }
        username = element.getText();

        while(true) {
        
            try{
                this.findWebElement = By.xpath("//*[@id=\"react-root\"]/section/main/div/div/article/div[2]/div[1]/ul/li/div/button/span");
                this.element = webDriverWait.until(ExpectedConditions.elementToBeClickable(this.findWebElement));

                this.element.click();
            } catch(Exception e) {
                break;
            }
            
        }
        
        try {
            commentList = driver.findElements(By.xpath("//*[@id=\"react-root\"]/section/main/div/div/article/div[2]/div[1]/ul/ul"));

            for (WebElement nthComment : commentList) {
                this.element = nthComment.findElement(By.xpath(".//div/li/div/div[1]/div[2]/h3/a"));

                if (this.element.getText().equals(username)) {
                    commentcheck = nthComment.findElement(By.xpath(".//div/li/div/div[1]/div[2]/span")).getText();

                    findHashtags(commentcheck, hashtags);
                }

                try {
                    this.element = nthComment.findElement(By.xpath(".//li/ul/li/div/button/span"));

                    while (true) {
                        this.element = nthComment.findElement(By.xpath(".//li/ul/li/div/button"));

                        if(this.element.findElement(By.xpath(".//span")).getText().equals("Hide replies"))
                            break;

                        this.element.click();
                    }

                    replyList = nthComment.findElements(By.xpath(".//li/ul/div"));

                    for (WebElement nthReply : replyList) {
                        this.element = nthReply.findElement(By.xpath(".//li/div/div[1]/div[2]/h3/a"));

                        if (this.element.getText().equals(username)) {
                            replycheck = nthReply.findElement(By.xpath(".//li/div/div[1]/div[2]/span")).getText();

                            findHashtags(replycheck, hashtags);
                        }
                        
                    }
                } catch (Exception e) {
                    continue;
                }
            }
        }
        catch(Exception e) {
            return hashtags;
        }

        return hashtags;
    }

 

해시태그는 항상 게시글 본문을 크롤링 한 뒤 크롤하게 됩니다. 따라서 webDriverWait는 댓글 수가 정말 많을 때 '더보기' 버튼의 로드만을 기다리면 되므로, 최대 대기 시간을 20초로 줄였습니다. 메소드에서 수행되는 첫 번째 while문이 이 '더보기' 버튼을 클릭하며, 가장 먼저 이 게시글의 모든 댓글들 로드를 끝냅니다.

 

게시글 본문 속 해시태그는 위에서 얻어왔으므로, 지금은 댓글과 대댓글만을 검사하며 글쓴이가 단 해시태그를 뽑아냅니다. 글쓴이 아이디는 로컬 변수 username 으로 선언했으며, 여기서 이걸 얻어오지 못하면 검사가 무의미하므로 바로 return 하게끔 구현했습니다. 

 

하나의 게시글은 1개의 commentList가 있고, commentList는 n개의 댓글들을 가지고 있습니다. 이 댓글들 각각은 n 사이즈의 replyList를 가지고 있습니다. 

맨 위 댓글부터 순서대로 대댓글을 뒤져가며, username에 저장된 아이디와 일치하는 사람이 단 댓글/대댓글을 검사합니다. 찾았을 경우, 위에서 첨부한 findHashtags() 코드를 호출하여 그 댓글/대댓글 내에서의 해시태그를 List<String> 에 추가 저장해옵니다.

 

 

앞에서 크롤링 자체에 관한 소스코드 설명은 많은 것 같아 별도로 하지는 않겠습니다. 배치 프로그램 내에서 수행하는 크롤링을 소개하는 포스팅은 여기서 마치겠지만, 'Spring' 카테고리 내에서 배치프로그램에 관한 포스팅이 이어질 예정입니다.

 

 

혹시 질문이나 고칠 점이 보이신다면, 댓글로 남겨주세요 ^^

 

 

 

 

반응형