네이버 검색에서 로또 당첨번호 파싱하기 – Swift + CommandLine

네이버 검색으로부터 로또 당첨 번호를 파싱하는 방법에 대해서 살펴보자. 기본적으로 이 작업을 수행하기 위해서는 두 가지 기술을 사용할 것이다.

  1. NSURLSession :  웹페이지 데이터를 받아와야 하기 때문에 네트워킹 API 를 사용해야 한다.
  2. NSRegularExpression : 받아온 데이터는 HTML 페이지의 소스 데이터이며, 여기서 로또 당첨번호의 내용을 추출하기 위해 간단한 정규식을 사용할 것이다.

아, 그리고 참고로 여기서 사용된 Swift 버전은 4.0이다.

준비 과정

네이버는 로또 당첨번호 검색에 대해서 일반적인 웹문서 결과가 아닌 별도로 디자인된 영역으로 당첨번호를 예쁘게 표시해주고 있다. 실제로 네이버에서 “로또 당첨번호”라는 키워드로 검색해보면 다음과 같은 화면을 볼 수 있다.

여기서 로또 회차 부분을 클릭하면 다른 회차의 당첨번호도 알 수 있는데, 다른 회차들을 선택해보자. 이 때, 브라우저의 주소창을 보면 일정한 패턴으로 구성되는 것을 알 수 있다.

https://search.naver.com/search.naver?sm=tab_drt&where=nexearch&query=792회로또

즉 위의 주소에서 회차에 해당하는 번호값만 바뀌는 것을 알 수 있다. 그러면 당첨 번호는 어떻게 알 수 있을까? 색색의 예쁜 숫자 공을 선택해서 브라우저의 인스펙터에서 조사해보면 “ball번호“의 패턴으로 클래스가 적용되어 있는 것을 확인할 수 있다.

따라서 ball(\d+) 라는 정규식 패턴으로 당첨번호에서 숫자부분만 손쉽게 추출할 수 있다.

정규식으로 당첨번호를 추출하여 출력하기

URLSession을 사용해서 간단한 데이터 받아오기라는 글에서 URLSession을 사용하는 간단한 방법을 소개한 적이 있는데, (Data) -> Void 타입의 완료 핸들러를 이용해서 웹주소로부터 HTTP 통신을 통해 받아온 데이터를 처리할 수 있다고 하였다. 그렇다면 이 부분을 먼저 작성해보자. 네이버는 웹페이지에 UTF8 인코딩을 사용하고 있으므로 받아온 데이터를 UTF8로 디코딩하여 문자열을 얻고, 여기에서 위 패턴을 적용하여 번호들을 추출할 수 있다.

func parseBallNumbers(_ data: Data) {
  guard let html = String(data:data, encoding:.utf8) else { return }
  let regex = try! NSRegularExpression(pattern: "ball(\\d+)", options:[])
  else { return }

  let matches = regex.matches(in: html, options:[], range: NSMakeRange(0, html.count))
  let results = matches.map{ (html as NSString).substring(with: $0.range(at:1)) }
  // 6개의 당첨번호와 1개의 보너스번호
  let nums = results[..<6].joined(separator: ", ")
  let bonus = results.last!
  print("\(nums) - 보너스: \(bonus)")
}

데이터를 요청하기

데이터를 받아와서 당첨번호를 추출하고 출력하는 함수를 작성했으니, 이제 데이터를 받아올 함수를 작성할 차례이다. 단, 나는 여기서 명령줄에서 실행되는 버전을 상정하고 있는데 URLSession의 모든 동작은 철저하게 비동기로 동작한다. 따라서 데이터 작업 객체의 resume()은 비동기로 네트워크 통신을 개시하면서 즉시 리턴하기 때문에 응답을 기다릴 필요가 있다. 이 경우, 메인 스레드에서 런루프를 실행하여 네트워크 통신을 기다리고, 완료 핸들러 내에서 런루프를 중지하고 끝내는 방식으로 처리한다.

func processURL(_ url: URL, handler: @escaping (Data) -> Void) {
  let task = URLSession.shared.dataTask(with: url){ data, _, _ in
    defer { CFRunLoopStop(CFRunLoopGetMain()) }
    guard let data = data else { return }
    handler(data)
  }
  task.resume()
  CFRunLoopRun()
}

조립하기

이제 우리는 네트워크 통신으로 웹 페이지를 받아오고, 데이터를 파싱하는 함수를 모두 갖게 되었다. 이를 조립하여 원하는 동작을 이루어보자.

func main() {
  if let s = readLine(), let i = Int(s) {
    let address = "https://search.naver.com/search.naver?sm=tab_drt&where=nexearch&query=\(i)회로또"
    let encodedAddress = address.appendingPercentEncoding(withAllowedCharacters:.urlQueryAllowed)!
    let url = URL(string:encodedAddress)!
    processURL(url, handler: parseBallNumbers)
}

NSRegularExpression : 정규식 사용하기 – Swift

Swift의 정규식

Swift는 언어 자체에서 정규식을 지원하지 않고 FoundationNSRegularExpression 클래스를 이용한다.

  1. NSRegulareExpressioninitthrows이기 때문에 try와 같이 사용되어야 한다.
  2. 매치 결과는 TextCheckingResult 클래스의 인스턴스를 얻게 된다. 이는 매치영역 및 영역 내 각 매치 그룹의 범위를 NSRange값으로 가지고 있다.
  3. 문제는 Swift 문자열의 부분문자열은 Index<String.Index>에 의해서 얻을 수 있지, NSRange를 이용할 수 없다. 따라서 이를 컨버팅하는 편의함수나 타입 확장을 이용해야 한다. (사실 이 부분은 Linux 버전의 Swift의 문제이다. Apple Swift에서는 Foundation/Cocoa를 임포트하게 되면  NSString의 API가 그대로 String으로도 노출되기 때문에 그대로 사용이 가능하다.)

NSRegularExpression : 정규식 사용하기 – Swift 더보기

숫자에 콤마넣기 (more!)

숫자에 콤마 넣기 (swift2)

숫자 세자리마다 콤마를 넣는 것은 NSNumberFormatter를 이용하면 간단히 할 수 있다.

let formatNumber: (Int) -> String = { n  in
    let f = NSNumberFormatter()
    f.numberStyle = .DecimalStyle
    return f.stringFromNumber(NSNumber(integer: n))!
}

숫자에 콤마넣기 (more!) 더보기

Objective-C / Swift :: NSRegularExpression

NSRegularExpression

Foundation은 유니코드 문자열에 대해서 정규식을 적용할 수 있는 NSRegularExpression 클래스를 제공한다. 이 클래스의 인스턴스는 컴파일된 정규식 패턴을 나타낸다. 여기서 사용되는 정규식 표현 패턴은 ICU의 안을 따르고 있다. (파이썬 정규식과 거의 유사하다.)

http://userguide.icu-project.org/strings/regexp

기본적으로 정규식 객체는 자신의 패턴을 문자열에 적용해서 매치 결과들에 대해서 실행되는 블럭 이터레이터를 제공한다. 그 외에도 매치 결과를 배열로 리턴하거나, 매치의 수를 찾거나, 첫 매치를 찾는 등의 편의 메소드도 제공한다.

각각의 매치 결과는 NSTextCheckingResult 객체인데, 이는 전체 매치의 범위(NSRange) 및 개별 캡쳐 그룹의 범위값을 가지고 있다.

객체 생성

기본적인 정규식 객체 생성 방법은 다음과 같다.

NSError *error = nil;
NSRegularExpression *regex = [NSRegularExpression 
            regularExpressionWithPattern:@"\\b(a|b)(c|d)\\b"
            options:NSRegularExpressionCaseInsensitive
            error:&error];

NSRegularExpressionCaseInsensitive는 대소문자 구분없는 매칭을 위한 옵션이다. swift에서는 .CaseInsensitive로 간소화되었다.(NSRegularExpressionOptions)

매치개수 세기

매치되는 부분의 개수는 numberOfMatchesinString:options:range:로 셀 수 있다. 여기서 옵션은 NSMatchingOptions의 값으로, 거의 nil을 쓴다고 보면 된다.

NSUInteger numberOfMatches = [regex numberOfMatchesInString:str
                                    options:0
                                    range:NSMakeRange(0, [str length])];

첫번째 매치 결과에 관심이 있다면 rangeOfFirstMatchInString:options:range:를 쓰면 된다.

NSRange rangeOfFirstMatch = [regex rangeOfFirstMatchInString:str options:0 range:NSMakeRange(0, [str length])];
if(!NSEqualRanges(rangeOfFirstMatch, NSMakeRange(NSNotFound, 0))) {
    NSString *substringForFirstMatch = [str substringWithRange:rangeOfFirstMatch];
}

모든 매치 결과는 matchesInString:options:range:로 구한다.

NSArray<NSTextCheckingResult> *matches = [regex matchesInString:str
                                options:0 range:NSMakeRange(0, [str length])];
for (NSTextCheckingResult *match in matches) {
    NSRange matchRange = [match range];
    NSRange firstHalfRange = [match rangeAtIndex:0];
    NSRange secondHalfRange = [match rangeAtIndex:1];
}

매치 결과의 범위 값은 NSTextCheckingResult-range로 구하는데, 만약 캡쳐링 그룹이 있다면 -rangeAtIndex:로 구한다. 그룹번호를 넣으면 되고, 그룹번호가 0인 경우에는 매치 전체의 범위가 된다.

편의상 첫번째 매치를 구하는 함수도 제공한다. -firstMatchInString:options:range:이고 사용법은 동일하다.

블럭이터레이터

-enumerateMatchesInString:options:range:usingBlock:은 각각의 매치에 대해서 블럭을 실행시킬 수 있는 메소드이다.

__block NSUInteger count = 0;
[regex enumerateMatchesInString:str options:0 range:NSMakeRange(0, [str length] usingBlock:^(NSTextCheckingResult *match, NSMatchingFlags flags, BOOl *stop){
    NSRange matchRange = [match range]; // 각 매치 전체의 범위
    NSRange firstHalfRange = [match rangeAtIndex:1]; // 첫 그룹
    NSRange secondHalfRange = [match rangeAtIndex:2]; // 두 번째 그룹
    if (count++ >= 100) *stop = YES; // 100번째 매치를 만나면 그만둔다.
})]

바꾸기

예시로 …

NSString *modifiedString = [regex stringByReplacingMatchesInString:str
                            options:0 range:NSMakeRange(0, [str length])
                            withTemplate:@"$2$1"];