본문 바로가기
루모의 일상사

[감성 분석] 1. 지속적 네이버 뉴스 크롤링

by xenophilius 2018.02.12


대량의 문서 속에서 형태소를 추출하여 감성을 분석 해 본다면 어떨까? 하는 물음에서 시작된 개인 프로젝트입니다. 이 프로젝트를 시작하기 위해 먼저 많은 문서를 수집해야 했었죠. 저는 국내의 정치, 사회, 경제면의 뉴스를 수집하기로 했습니다. 그러기 위해선 웹 크롤러를 만들어야 했습니다. 이 글은 제가 네이버 뉴스 페이지를 크롤링 하면서 겪었던 문제와 고민, 그리고 나름의 해결방법에 대해서 정리한 


저는 데이터 마이닝 전공이 아니기 때문에 틀린 용어나 방법들이 있을 수 있습니다. 다만 저는 어떻게 해결하려고 했는지, 그 방법에 대해 공유합니다.



어떻게 크롤링 할까 


처음 생각한 방법은 간단 했습니다. 일정 시간마다 네이버 뉴스 페이지를 방문해서 텍스트를 긁어 오는 것입니다. 예를들어 10분마다 네이버뉴스 페이지에 Http 요청을 보내서 뉴스를 긁어 온다고 생각 해 봅시다. 그렇다면 데이터베이스에는 아래와 같이 저장 되겠죠.


 news_id

headline 

date 

publisher 

1

제목 A

 2018-02-12 13:46

연합뉴스

2

제목 B

 2018-02-12 13:44

중앙일보

3

제목 C

 2018-02-12 13:41

조선일보



자 이제 10분이 지났습니다. 새로운 뉴스가 올라왔을지도 모르니 다시 한 번 크롤링 해야겠죠? 그런데 10분 사이에 새로운 뉴스가 나오지 않았다면 어떻게 될까요? 그냥 크롤링 했다간 똑같은 뉴스가 데이터베이스에 추가 될 것입니다. 


 news_id

headline 

date 

publsiher 

1

 제목 A

 2018-02-12 13:46

연합뉴스

2

 제목 B

2018-02-12 13:44

중앙일보

3

 제목 C

2018-02-12 13:41

조선일보

4

 제목 A

2018-02-12 13:46

연합뉴스

5

 제목 B

2018-02-12 13:44

중앙일보

6

 제목 C

2018-02-12 13:41

조선일보



어.... 이러면 안됩니다. 이런식으로라면 계속해서 중복기사가 쌓이고 쌓이겠죠. 이미 수집한 기사는 데이터베이스에 저장하지 않도록 해야합니다. 간단하죠 뭐 긁기전에 날짜를 보고 긁도록 합시다. 네이버 뉴스 페이지의 가장 상단에 올라 와 있는 기사의 날짜와 데이터 베이스에 저장된 가장 최신의 기사 날짜를 비교하는 것이지요. 


SELECT date FROM news ORDER BY date DESC LIMIT 1


위와 같이 데이터베이스에 쿼리하면 간단하게 내가 가장 최근에 수집한 기사의 날짜를 알 수 있을 겁니다. 위 쿼리의 결과를 대충 ‘오후 5시’라고 가정하겠습니다. 날짜까지 쓰면 읽기 복잡하니까요. 그러면 뉴스 페이지를 긁을 때 ‘오후 5시’ 전에 올라온 기사들은 다 무시하고 이후에 올라온 기사만 데이터베이스에 저장 하면 될 것입니다.


Date lastInsertedDate = getLastInsertedDate()

for news in NaverNews:
        if news.date > lastInsertedDate:
		InsertNews(news)
	else:
		break


반복해서 모든 헤드라인을 볼 필요 없이 가장 상위의 헤드라인만 보고 아니다 싶으면 반복문을 탈출하면 될 것같습니다. 어차피 네이버 뉴스 페이지는 시간순으로 정렬 되어 있으니까요.






동시에 올라온 뉴스


그러나 이대로도 문제가 있습니다. 뉴스가 올라온 시각은 ‘분’ 단위로 제공 되고 있습니다. 만약에 뉴스 페이지를 긁으러 갔는데 아래와 같이 되어 있다면 어떨까요?



 기사 제목

날짜 

 B

 오후 5시 00분

 A

 오후 5시 00분

 ...

... 




제가 A까지 데이터베이스에 저장 해 뒀다고 가정하겠습니다. 그러면 이제 B를 긁어야 하지만... 어? 두 뉴스의 날짜가 같습니다. 이러면 B 뉴스를 긁지 않고 그냥 반복문을 탈출 할 겁니다. 어차피 뉴스 기사 수천개를 수집해서 분석 할 텐데 한 개 쯤은 그냥 괜찮겠죠? 그렇지 않습니다. 안타깝게도 뉴스 기사들도 일정 시간마다 한 번에 좌르륵 포스팅 되는 경향이 있어서 아래와 같은 경우가 생길 수 있습니다.


 기사 제목

날짜 

 D

 오후 5시 00분

 C

 오후 5시 00분

 B

 오후 5시 00분

 A

 오후 5시 00분


제가 만약 A까지만 수집 한 상태에서 크롤링을 하러 갔는데 위와 같은 상황이면 D, C, B 기사를 긁지 못하겠죠. 양이 많으면 많을 수록 누락은 커질 것입니다.


어떻게 하면 좋을까요? 날짜 뿐만 아니라 뉴스의 제목까지도 비교하면 좋을 것 같습니다. 제목까지도 일치한다면 정말 같은 기사니까 탈출하도록 하고, 그렇지 않으면 같은 기사가 아니므로 수집하게 하는 겁니다.



// 날짜가 같은지 검사, 그리고 제목 마저 같은가?
If (news.date.compareTo() == 0 && news.headline.equal())
	break;



이걸로 괜찮아 보이네요. 실제로 제가 이런 알고리즘을 작성하고 18시간동안 수집을 해 보았습니다. 그런데 네이버 뉴스 페이지에 올라온 기사는 약 320개인데 데이터베이스에는 고작 280개의 기사 밖에 저장 되어 있지 않았습니다. 이게 대체 어떻게 된 일 일까요? 알고리즘에는 문제가 없어 보였습니다. 



몇시간 정도 네이버 뉴스 페이지를 살펴 보다가 문제가 무엇인지 알아냈습니다! 문제는 바로 네이버도 뉴스를 크롤링하는 입장이라는 것입니다. 이것이 무슨말이냐, 결국 네이버도 각 신문사로부터 뉴스를 크롤링해서 자기네들 페이지에 뿌려주는 것입니다. 자기네들도 놓치는 기사가 있을 수도 있고 뒤늦게 그것을 추가하는 경우가 있습니다.



 기사 제목

 기사 날짜

 A

 2018-02-01 15:00

 B

  2018-02-01 13:00

 C

  2018-02-01 12:00




10분 뒤




기사 제목 

기사 날짜 

 A

 2018-02-01 15:00 

 X

  2018-02-01 14:00

 B

  2018-02-01 13:00




기사 A, B, C가 있습니다. 물론 날짜순으로 정렬된 상태로요. 크롤러가 이것들을 긁어 갑니다. 여기까지는 좋습니다. 그런데 갑자기 X라는 기사가 중간에 푹 끼어듭니다. 뒤늦게 리스트에 추가 된거죠. 10분뒤 크롤러가 페이지를 다시 긁으러 올 때는 중간에 낀 X라는 녀석을 볼 수가 없겠죠. 상위의 뉴스 제목과 날짜만 보고 바로 반복문을 탈출 해 버릴테니까요. 나름 최적화 한다고 넣은 break 구문이 오히려 독이 되었습니다.


그렇다면 break를 삭제하고 모든 뉴스 기사 제목과 날짜를 둘러보면 될까요? 그러려면 앞서 데이터베이스의 가장 최신 기사를 가져오는 것이 아니라 비교해야할 모든 기사를 가져와야 합니다.


네이버 뉴스는 1페이지에 약 20개의 기사 리스트를 유지하고 있습니다. 그러니 20개의 기사를 가져와서 비교를 해야 되겠군요. 날짜와 제목을 각각 비교하므로 최대 40번의 비교를 하게 될 것입니다. “까짓거 그냥 하면 안 돼? 별로 많지도 않네”라는 생각이 듭니다. 


그러나 X처럼 중간에 끼어드는 뉴스기사가 1개가 아니라 10개라면 어떨까요? 한 페이지에 20개 밖에 보여주지 않으니까 밀린 기사는 2페이지로 넘어가게 될 겁니다. X같은 (욕 아님) 기사는 언제 어디서 중간에 끼어들지 예측할 수 없기 때문에 결국 1페이지와 2페이지를 모두 검사해야 합니다. 만약 지금까지 올라온 페이지가 20페이지라면 어떨까요? 무려 400개의 기사를 모두 검사해야 할지도 모릅니다. 역시 매번 String을 비교하는 것은 뭔가 ‘프로그래머’스럽지 않습니다



HashSet을 사용하여 검사하기


저는 HashSet를 응용해서 좀 더 빠르게 비교 해 보도록 했습니다. 




1. 네이버 뉴스가 1페이지에 20개의 기사 리스트를 가지고 있으니 제 데이터베이스에서 20개의 뉴스를 가져옵니다. 

List top20List = Query(“SELECT * FROM news ORDER BY date DESC LIMIT 20”);



Set top20Set = new LinkedHashSet<>(top20List.size());
for (News news : top20List) {
	top20Set.add(news.getUrl().hashCode());
}


top20List에는 데이터베이스에서 가져온 상위 20개의 뉴스기사가 있습니다. 20개의 기사를 순회하면서 각 URL의 hashCode를 구하고 HashSet에 넣어주도록 합니다.


사실은...

처음에 기사를 크롤 하고 저장할 때 URL의 hashCode를 구해서 같이 DB에 저장 해 주면 좋겠죠. 이 글에서는 그냥 이렇게 설명 하겠습니다.


3. HashSet이 갖춰 졌으니 이제는 네이버 뉴스 페이지를 크롤링 하면서 HashSet과 비교 해 줍시다.


for (News n : NaverNewsList) {
	 if (!top20Set.contains(n.url.hashCode())) {
		// 없는 기사
 	 } 
}




4. HashSet에 hashCode가 없다면 새로 올라온 기사거나 중간에 끼어든 기사이니 데이터베이스에 넣어줍니다. 위 코드의 주석 부분에 이러한 코드를 넣어 주면 되겠습니다.


사실은...


네이버 뉴스에는 aid라는 10자리로 이뤄진 반쯤(?) 고유한 기사 번호가 있습니다. 뉴스 URL을 보면 GET으로 요청하고 있는 것을 알 수 있습니다. 그런데 크롤링을 하는 입장에서 이런 변수들은 네이버측에서 언제 어떻게 바꿔 버릴지 알 수 없어서 이 글에서는 그냥 기사 URL을 통째로 hashCode를 만드는 방식으로 소개 했습니다. 개발하는 사람 입맛에 따라 네이버의 aid를 사용하셔도 될 것이고 또는 URL의 oid와 aid를 조합해서 hashCode를 만드는 등 여러분 나름의 구현을 하면 되겠습니다. oid는 신문사별 고유 id인 것 같습니다.

http://news.naver.com/main/read.nhn?mode=LSD&mid=shm&sid1=100&oid=003&aid=0008441560


또한 네이버 뉴스 페이지의 Http Response를 잘 보시면 'article type'과 'articleId'라는 JSON 항목도 볼 수 있습니다. 게다가 여기서는 기사가 올라온 시각을 '초'단위로 볼 수도 있습니다. 


http://news.naver.com/main/mainNews.nhn?componentId=949987&page=1


그래서 바로 뒤에 설명할 기사 제목이 수정 되는 경우도 'articleDate'라는 부분을 살펴보면 쉽게 알 수 있습니다. articleType은 아마 동영상이 포함된 기사이면 2, 아닐경우 1로 표현하고 있는 것 같습니다.



크롤링하기에는 Daum의 뉴스가 훨씬 나은거 같습니다. 네이버의 JSON은 불필요한 key가 엄청 많은데 다음은 그렇지가 않은 것 같습니다. 심지어 다음은 keyword라는 필드가 아예 있습니다! 네이버에 비해 다음이 더 깔끔하게 정리 해 놨다는 느낌을 받습니다. 다음을 씁시다 다음.




이 코드로 24시간 동안 수집하도록 해 보니 중간에 끼어드는 기사도 누락 없이 잘 수집합니다. 이걸로 끝나면 정말 좋겠지만 안타깝게도 또 다른 문제가 산더미 처럼 쌓여 있습니다. 바로 기사가 수정되는 경우 입니다.




기사 제목이 수정 되면?


기사가 수정되는 경우는 생각보다 허다합니다. 특히 긴급 속보의 경우가 그렇습니다. 급하게 작성해서 올리다보니 오류가 많고 나중에 가서는 수정이 필요한 것이죠. 이런 문서들은 데이터베이스에서 업데이트 해 줄 필요가 있습니다. 수정된 기사를 감지 하지 못한다면 어떻게 될까요? 예를들어 어떤 기사의 제목이 아래와 같이 변경 되었다고 생각 해 봅시다.


밀양 세종병원 화재사고 90대 1명 져…사망자 49명으로 늘어


밀양 세종병원 화재사고 90대 1명 져…사망자 49명으로 늘어



기사에 오타가 있었네요. '슴'을 '숨으로 변경 하였습니다. 나중에 형태소 단위로 문장을 분리 해 내면 '슴져'로 분리 될 것이고 이는 감성 사전에서 찾을 수 없는 단어이므로 정확한 데이터 분석을 할 수 없게 됩니다. 그렇다면 기사가 수정 됐는지 아닌지 역시 감지해서 반영할 필요가 있습니다. 하지만 어떻게요?



다행히 네이버 뉴스는 기사의 수정된 날짜를 제공하고 있습니다. ‘기사 최신 수정일’이라는 항목이 <span> 태그로 있기 때문에 이것을 활용 하면 될 것입니다. 






for (News n : NaverNewsList) {
	 if (!top20Set.contains(n.url.hashCode())) {
		// 완전히 새로운 기사.
 	 } else {
 		// 수정 된 이력이 있는 기사인가?
 		if (n.isModified()) {
 			// 넵!
 		} else {
 			// 아니요.
		}
	}
}


수정이 된 이력에 대해서 알아야 하므로 기존에 사용중이던 Set은 HashMap으로 변경하는 것이 좋을 것 같습니다. Key는 여전히 URL의 hashCode()로 하고 Value는 수정된 날짜가 되면 좋겠군요. Map<Integer, Date>라고 하면 충분할 것 같습니다.



// 수정됐을 가능성이 있는 기사
Document article = Jsoup.connect(articleUrl).get();
String articleModified = article.select("#main_content > div.article_header > div.article_info > div > span.t11:nth-child(1)").text();

if (!articleModified.isEmpty() && articleModified.compareTo(top20Set.get(articleUrl.hashCode())) != 0) {
    // TODO
    // update
}




라이브러리


JSOUP

프로젝트를 진행하면서 사용한 JAVA 라이브러리 입니다. 이걸 주로 사용했습니다.

https://jsoup.org/





댓글4

  • 2019.01.04 14:49

    비밀댓글입니다
    답글

    • xenophilius 2019.01.04 17:03 신고

      코드는 공유 해 드릴 수 있으나 mySQL 서버도 있어야하고 JAVA Spring에 대한 이해도 필요합니다. 메일 남겨주시면 공유 드리겠습니다. 근데 간단하게 크롤링 해 볼 수 있는 python 코드는 인터넷에 널려있어 그걸 참고하시는게 더 빠르실듯 ㅇㅇ.

  • 2019.01.11 17:03

    비밀댓글입니다
    답글

    • xenophilius 2019.01.11 18:42 신고

      어느 부분에서 막혔는지 알려주시면 도와드릴게요. 코드는 전혀 정리가 안되서 저 조차도 기억이 안 날 정도...