본문 바로가기
카테고리 없음

단어 검색기 만들기

by 경자꿈사 2024. 10. 2.

이전 글에서 웹 스크래핑을 사용해 현재 주식 가격 정보를 조회할 수 있는 프로그램을 만들어 봤는데, 오늘은 네이버 사전을 사용하여 영어나 국어의 단어 등을 쉽게 조회해 볼 수 있는 프로그램을 만들어 보겠다.
우선 사용하고 있는 웹 브라우저를 열어 주소창에 dict.naver.com 을 입력하고 엔터를 누르자.
그러면 네이버 사전의 홈 화면이 보일텐데, 검색창에 원하는 영어 단어 하나를 입력하고 엔터를 눌러 보자.
아래 그림을 보면 'ruby'라는 단어를 검색한 결과 화면을 볼 수 있는데, 우리는 젤 위에 보이는 정보(루비, 홍옥 / 다홍색)만 가져와 화면에 표시해 주도록 프로그램을 만들어 보자.


F12를 눌러 개발자 도구를 실행한 후, 화면에 보여지는 특정 위치의 HTML 코드를 확인할 수 있는 기능을 사용하여 

'1. 명사 루비, 홍옥' 을 선택해 보자.
개발자 도구 화면 좌측 상단의 화살표가 포함된 아이콘(아래 그림 참조)을 클릭한 후, 마우스 포인터를 네이버 사전이 표시된 화면으로 이동해 보면 원하는 부분을 선택할 수 있도록 해당 영역이 다른 색으로 표시되는 걸 볼 수 있다.
특정 부분을 클릭하게 되면 해당하는 부분의 HTML 코드가 개발자 도구에 표시가 되는데, 그림을 보면 <li data-v-bca6c8fa="" class="mean_item multi"> 엘리먼트 아래 우리가 가져 오려는 텍스트가 포함되어 있는 걸 볼 수 있다.


실제로 결과 화면에 해당하는 HTML 코드에 개발자 도구에서 확인한 엘리먼트가 포함되어 있는지 확인하기 위해 아래처럼 코드를 작성하여 실행해 보자.
아래 코드에 대한 기본적인 설명은 '주식 가격 조회하기1' 글을 참고하길 바란다.

require 'cgi'
require 'net/https'

http = Net::HTTP.new("dict.naver.com", 443)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_NONE 

response = http.get("/dict.search?query=ruby")
html = response.body
File.open("dic.html", "wb") { |f| f.write(html) }

D:/blog/ruby/dictionary 폴더 아래 dic.rb 파일로 저장한 후에 같은 폴더에서 cmd 창을 열어 실행해 보면 dic.html 파일이 생성되는 걸 볼 수 있다.

그 다음 dic.html 파일을 텍스트 편집기로 열어 <li data-v-bca6c8fa="" class="mean_item multi"> 엘리먼트의 class 속성 정보인 'mean_item'으로 파일 내용에서 검색을 해 봤지만 찾을 수 없었다.
그래서, 실제 화면에 보여지는 '루비, 홍옥'으로 검색을 다시 했더니 찾을 수 있었다!

아마도, 우리가 개발자 도구를 통해 확인한 내용은 웹 브라우저에서 javascript를 통해 어떠한 작업(HTML 테그 생성 등)을 한 후의 내용인 것 같다.
다행히 실제 우리가 사용할 데이터가 dic.html 파일에 포함되어 있기 때문에, 이제는 이 데이터를 전체 HTML에서 어떻게 잘 뽑아낼지에만 집중하면 된다.
텍스트 편집기에서 dic.html 파일의 내용 중, 우리가 필요한 부분만 다시 잘 살펴보길 바란다.
그러면 첫 번째 'meanList'가 나오는 부분 [{... mean:"루비, 홍옥" ...}, {... mean:"다홍색" ...}] 에 우리가 원하는 모든 데이터가 포함되어 있는 걸 볼 수 있다.
이 데이터 형식을 보면 우리가 루비에서 많이 봐왔던 배열 그리고 해시와 상당히 유사함을 알 수 있다.
실제 이 데이터는 JSON(JavaScript Object Notation) 포맷의 데이터로서 주로 웹 상에서 데이터를 교환하기 위해 많이 사용하고 있다.
웹 브라우저에 내장된 JSON 파서를 사용하여 서버로부터 받은 JSON 포맷의 데이터로부터 javascript 객체를 생성하고 나면 이후에는 객체를 사용하여 데이터를 화면에 표시(HTML 태그 생성 등)하는 등의 작업을 처리한다.
그리고 루비에서도 JSON 포맷의 데이터를 루비 객체로 변환할 수 있고, 그 반대도 가능하다. 물론 모든 루비 객체가 JSON 포맷과 호환되지는 않고 보통은 문자열, 숫자, 불린, 배열, 해시 등을 사용한다.
아래 그림을 보면 루비 객체를 JSON 데이터로 변환한 후 다시 루비 객체로 변환하는 걸 볼 수 있는데, 심볼이었던 키가 문자열로 바뀐 것만 빼고는 해시 객체로 다시 잘 생성되었다.

서버로부터 받은 HTML 데이터에서 우리가 필요한 부분만 뽑아낼 방법이 여러 가지 있겠지만, 여기서는 간단히 정규 표현식을 사용하려고 한다.
우선 위의 코드를 아래처럼 수정한 후 다시 실행해 보자.

require 'cgi'
require 'net/https'

http = Net::HTTP.new("dict.naver.com", 443)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_NONE 

response = http.get("/dict.search?query=ruby")
html = response.body
md = html.match(/meanList:\[(.+?)}\]/)
puts md[1]


일단 여기까지는 잘 뽑아 왔고, 이제 "루비, 홍옥" 과 "다홍색" 만 다시 발라내보자.
그전에 match 메서드에 인수로 준 정규 표현식에서 '?'가 의미하는 것을 간단히 설명하면, 그 앞에 있는 '+'가 최소한으로 매칭시키도록 제한하는 역할을 해준다.
'*'와 '+'는 정규 표현식과 대상 문자열을 최대한 매칭시키도록 하는 것이 기본 동작이기 때문에, 앞의 정규 표현식에서 '?'를 빼버리면 서버로부터 받은 HTML 데이터의 내용에서 가장 끝에 나오는 '}]' 두 문자 직전까지 매칭시키게 된다.
위의 코드에서 정규 표현식을 /meanList:\[(.+)}\]/ 로 수정하고 테스트해 보면 화면에 상당히 많은 내용이 출력되는 것을 볼 수 있다.

정규 표현식에서 최대한 많이 매칭시키려고 하는 방식을 greedy 매칭이라고 하고, 그 반대를 non-greedy 매칭 또는 lazy 매칭이라고 한다.
그래서, 우리가 원하는 첫 번째 meanList의 데이터만 매칭시키기 위해 '+' 뒤에 '?'를 붙여 탐욕적이지 못하도록 막은 것이다.

다음으로 위에서 추출한 문자열에서 실제 우리가 원하는 데이터를 뽑아내기 위해 문자열의 scan 메서드를 사용할 건데, 그전에 먼저 scan 메서드에 대해 잠깐 살펴보도록 하자.
아래 그림을 보면 문자열 str에 대해 인수를 각각 다르게 하여 scan 메서드를 호출하고 있다. 

기본적으로 scan 메서드는 인수와 매칭되는 문자열을 모두 찾아 배열로 돌려주는데, 인수가 문자열이면 대소문자까지 정확히 일치하는 문자열을 찾는다.
즉, 아래 그림처럼 인수로 "love" 나 "Love"를 주면 대상 문자열에서 하나만 찾게 되고, "LOVE"를 인수로 주면 하나도 찾지 못해 빈 배열을 돌려준다.
따라서 대상 문자열에서 정확히 일치하는 문자열이 아니라 패턴이 일치하는 문자열을 찾으려면 scan 메서드의 인수로 

/love/i 처럼 정규 표현식을 줘야 한다.
정규 표현식 /love/i 에서 끝에 붙은 'i'는 'ignore case'를 의미하고 대소문자를 무시하고 매칭시키고 싶을 때 사용한다.
그리고 정규 표현식 안에 (.+) 처럼 capturing group(소괄호로 묶은 서브 정규 표현식)을 포함하게 되면 scan 메서드는 결괏값으로 매칭되는 전체 문자열이 아니라 capturing group에 해당하는 부분만 돌려준다.
참고로, match 메서드는 MatchData 객체를 반환하는데, 배열처럼 MatchData 객체에 대해 [] 연산자 메서드를 사용하여 매칭되는 문자열을 참조할 수 있다.
위의 예제처럼 match 메서드의 결괏값을 md라는 변수에 할당했다면, md[0]은 매칭되는 전체 문자열을 돌려주고 md[1]은 정규 표현식 안의 첫 번째 capturing group에 해당하는 문자열을 돌려준다.
만약, match 메서드에 전달된 정규 표현식에 두 개의 capturing group이 있었다면 md[2]로 두 번째 capturing group에 해당하는 문자열을 참조할 수 있다.
다시 아래의 scan 메서드 예제로 돌아와서, 정규 표현식을 /love (.+)/i 이렇게 주면 앞서 얘기한대로 '+' 가 greedy하게 매칭을 시키기 때문에 (.+)은 'Love ' 다음에 오는 문자, 즉 'R' 부터 문자열 끝까지를 매칭시키게 된다.
결과를 보면 2차원 배열 [["Ruby, I love Java"]] 을 돌려준다. 왜 2차원 배열로 돌려주는지는 젤 아래 코드를 보면 알 수 있다.
그다음 '?'을 사용하여 non-greedy 하게 매칭 시키기 위해 정규 표현식을 /love (.+?)/i 이렇게 주게 되면, 너무 빨리 매칭을 끝내버려 결괏값이 [["R"], ["J"]] 이 되어 버렸다.
내가 원하는 것은 [["Ruby"], ["Java"]]이다. 어떻게 해야 할까? 이때는 단어의 경계를 나타내는 '\b'을 사용하면 된다.
정규 표현식에서 말하는 '단어'는 알파벳 대소문자(A-Za-z)와 숫자(0-9), 그리고 밑줄 문자(_)로만 이루어진 문자열을 의미하고 '\w'는 단어(word) 문자 하나와 매칭된다.
즉, '\b'는 단어에 해당하는 문자(\w)와 단어가 아닌 문자(공백(\s), 구두점, 특수 문자 등) 사이의 경계와 매칭된다고 보면 된다. 쉽게 말해 '\b'는 단어가 끝나는 지점을 찾을 수 있게 해준다고 보면 된다.
그래서 정규 표현식을 /love (.+?)\b/i 이렇게 주면, '?'으로 '+'을 non-greedy 하게 만들지만 최소한 단어 경계('\b')를 만날 때까지는 매칭 시키도록 한다.
결국, str.scan(/love (.+?)\b/i) 을 실행하면 내가 원했던 결과인 [["Ruby"], ["Java"]] 을 얻을 수 있게 된다.
마지막 예제를 보면 정규 표현식 안에 capturing group이 두 개 포함되어 있고 결괏값은 [["Ruby", "Language"], ["Java", "language"]] 로 나온다.
즉, 정규 표현식 안에 capturing group이 있다면, capturing group은 두 개 이상도 될 수 있으므로 최종 결과는 2차원 배열 형태가 되어야 함을 알 수 있다.

이제, scan 메서드를 사용하여 앞서 match 메서드를 사용하여 1차적으로 추출한 문자열에서 실제 원하는 데이터만 걸러내 보자.

require 'cgi'
require 'net/https'

http = Net::HTTP.new("dict.naver.com", 443)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_NONE 

response = http.get("/dict.search?query=ruby")
html = response.body
md = html.match(/meanList:\[(.+?)}\]/)
puts md[1].scan(/mean:"(.+?)"/)

마지막으로, 원하는 만큼 반복해서 단어를 검색할 수 있도록 프로그램을 개선해 보자.
아래 코드를 보면 Dictionary 라는 클래스를 만들어서 initialize 메서드에서는 네이버 사전 서비스를 제공하는 서버로의 HTTPS 접속을 위해 필요한 초기화 작업을 하고, 실제 단어의 뜻을 검색하기 위한 기능은 get_mean 이라는 메서드로 만들었다.
get_mean 메서드에서는 먼저, 서버로 요청이 올바르게 전달될 수 있도록 인수로 받은 문자열을 CGI 클래스의 'escape' 클래스 메서드를 사용하여 URL 인코딩 처리를 해주었다.
실제 URL 인코딩 처리 부분을 뺀 채로 한글 단어를 검색해 보면 그 차이를 알 수 있다.
그리고, 서버로부터 찾고자 하는 데이터를 잘 받았다면 scan 메서드의 결괏값인 2차원 배열을 1차원 배열로 변환해서 반환하고, 받지 못했다면 nil을 반환하도록 하였다.
나머지 사용자로부터 단어를 입력받아 검색하고 화면에 보여주는 코드는 별도의 파일로 분리해도 되지만, 간단히 하기 위해 같은 파일에 작성하였다.

require 'cgi'
require 'net/https'

class Dictionary
  def initialize
    @http = Net::HTTP.new("dict.naver.com", 443)
    @http.use_ssl = true
    @http.verify_mode = OpenSSL::SSL::VERIFY_NONE
  end

  def get_mean(word)
    query = CGI.escape(word)
    response = @http.get("/dict.search?query=#{query}")
    html = response.body

    md = html.match(/meanList:\[(.+?)}\]/)
    if md
      md[1].scan(/mean:"(.+?)"/).flatten
    end
  end
end

dic = Dictionary.new
word = ARGV.join(" ")

loop do
  if word.empty?
    print "검색할 단어를 입력해 주세요(종료는 엔터) > "
    word = STDIN.gets.chomp
  end
  break if word.empty?

  mean = dic.get_mean(word)

  if mean
    puts mean
  else
    puts "해당하는 단어의 뜻을 찾을 수 없습니다"
  end
  word = ""
end

아래 그림처럼 영어 단어와 한글 단어 모두 검색이 잘되고, dic.rb 파일을 실행할 때 파일명 뒤에 커맨드라인 인수로 검색할 단어를 함께 적어줘도 된다.
dic.rb 소스에서 word = ARGV.join(" ") 코드가 커맨드라인 인수가 있을 때 그 값을 처음 검색할 단어로 사용하기 위한 코드이다.

그런데, 느낌표를 검색한 결과를 보면 뒤에 ( \u003Cstrong\u003E!\u003C\u002Fstrong\u003E ) 가 보이는데, 실제 웹 브라우저를 통해 네이버 사전에서 느낌표를 검색해 보면 그 이상한(?) 문자열이 ( ! )임을 알 수 있다.


아래 그림처럼 irb 에서 확인해 보면 실제 "( <strong>!</strong> )" 이렇게 나오는데 HTML에서 strong 태그는 텍스트를 굵게 표시하기 위해 사용한다.


우리가 만든 프로그램에서는 strong 태그 등의 처리를 해주기가 어려우니 결과 문자열에서 HTML 태그를 모두 삭제하자.
아래 최종 코드가 있는데 실제 변경된 부분은 한 줄이다. 

require 'cgi'
require 'net/https'

class Dictionary
  def initialize
    @http = Net::HTTP.new("dict.naver.com", 443)
    @http.use_ssl = true
    @http.verify_mode = OpenSSL::SSL::VERIFY_NONE
  end

  def get_mean(word)
    query = CGI.escape(word)
    response = @http.get("/dict.search?query=#{query}")
    html = response.body

    md = html.match(/meanList:\[(.+?)}\]/)
    if md
      md[1].scan(/mean:"(.+?)"/).flatten
    end
  end
end

dic = Dictionary.new
word = ARGV.join(" ")

loop do
  if word.empty?
    print "검색할 단어를 입력해 주세요(종료는 엔터) > "
    word = STDIN.gets.chomp
  end
  break if word.empty?

  mean = dic.get_mean(word)

  if mean
    mean.each { |e| puts e.gsub(/\\u003C.+?\\u003E/, "") }
  else
    puts "해당하는 단어의 뜻을 찾을 수 없습니다"
  end
  word = ""
end


기존 코드는 get_mean 메서드로 받은 결괏값을 있는 그대로 출력했었는데, 변경한 코드에서는 결과로 받은 배열 안의 모든 문자열들에 대해 gsub 메서드를 사용하여 HTML 태그 패턴을 찾아 빈 문자열로 변경한 후 출력하도록 했다.
아마도, 이런 저런단어로 계속 검색하다 보면 또 이상한 값이 나올 수도 있을 텐데, 여러분이 직접 개선해 보길 바란다.
끝으로, 오늘 만든 프로그램을 컴퓨터 환경 변수 'Path'를 사용해서 설정하면 좀 더 쉽게 사용할 수 있다. 나도 실제 그렇게 사용하고 있다.
컴퓨터 환경 변수 'Path' 설정 관련 내용은 '한 줄 메모장 만들기' 글을 참고하길 바란다.
See you agian~~