[토이 프로젝트] 자동화된 게임 명부 만들기(1) - GAS 사용

0. 이번 단계 진행 아이디어

IMPORTXML 말고 GAS로 좀 깔끔하게 명부를 자동화해보자. 트리거 활용도 되니깐 더 좋다. 😀

1. apps script 설정하기

기본적인 설정과 코드는
GAS 설정
,
Cheerio 사용
두 개의 글 링크로 대체합니다.

위 글들을 바탕으로 메이플 명부 갱신용 기본 코드를 짜보겠습니다.


GetMapleData.gs

function getContent_(name) { // 캐릭터 검색 결과 페이지를 반환
  const baseUrl = "https://maple.gg/u/";
  const url = baseUrl + name;
    try {
      const response = UrlFetchApp.fetch(url)
      if (response.getResponseCode() == 200) {
        return response.getContentText();
      }
      else {
        return null;
      }
    }
    catch (error) {
      Logger.log(error);
      return null;
    }
}
function getMapleData() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("명부"); // 시트 선택
  const datarange = sheet.getDataRange(); // 데이터 범위 가져오기
  const numRows = datarange.getNumRows(); // 총 행의 수 가져오기
  const namePos = 1; // 캐릭터명의 위치는 각 행의 1열임 
  const worlds = ["루나", "스카니아", "엘리시움", "크로아", "오로라", "베라", "레드", "유니온", "이노시스", "제니스", "아케인", "노바", "리부트1", "리부트2"];

  for (var i = 3; i <= numRows; i++) { // 3행부터 데이터가 있음
    const name = sheet.getRange(i, namePos).getValue(); // 캐릭터명 가져오기
    if (name === "" || worlds.includes(name)) { // 빈칸이거나 캐릭터명이 아닌 월드명일 경우 거름
      continue; 
    }
    html = getContent_(name);
    if (html === null) {
      Logger.log(name + ": 스크래핑 오류 발생!");
      continue;
    }
    const $ = Cheerio.load(html);
    console.log(name);
  }
}

function setSheetData(sheet, data){

}

  • getContent_(name): 캐릭터명을 인자로 받아 캐릭터 검색 결과 페이지를 반환하는 함수입니다. 오류 발생 시 null을 반환합니다.
  • getMapleData(): 결과 페이지로부터 명부에 입력할 데이터를 스크래핑하는 함수입니다. 데이터를 긁어내는 기능은 아래에서 추가할 예정입니다.
  • setSheetData(): 긁어낸 데이터를 각 캐릭터 행에 입력하는 함수입니다. 마찬가지로 아래에서 기능을 추가할 예정입니다.

2. maple.gg 스크래핑하기

1) robots.txt 확인

robots.txt를 확인한 결과 스크래핑에 아무제약이 없음을 확인할 수 있습니다.

2) element 추출하기

이제 앞서 작성한 스크립트에 element를 추출하는 코드를 추가하여 getMapleData()를 완성해보겠습니다.

maple.gg의 결과페이지에서 selector 경로를 복사하여 cheerio를 통해 각 데이터를 추출하겠습니다.


GetMapleData.gs - getMapleData()


...

function getMapleData() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("명부"); // 시트 선택
  const datarange = sheet.getDataRange(); // 데이터 범위 가져오기
  const numRows = datarange.getNumRows(); // 총 행의 수 가져오기
  const namePos = 1; // 캐릭터명의 위치는 각 행의 1열임 
  const worlds = ["루나", "스카니아", "엘리시움", "크로아", "오로라", "베라", "레드", "유니온", "이노시스", "제니스", "아케인", "노바", "리부트1", "리부트2"];
  let data = []; // 시트 입력용 데이터

  for (var i = 3; i <= numRows; i++) { // 3행부터 데이터가 있음
    const name = sheet.getRange(i, namePos).getValue(); // 캐릭터명 가져오기
    if (name === "" || worlds.includes(name)) { // 빈칸이거나 캐릭터명이 아닌 월드명일 경우 거름
      continue; 
    }
    html = getContent_(name);
    if (html === null) {
      Logger.log(name + ": 스크래핑 오류 발생!");
      continue;
    }
    const $ = Cheerio.load(html);
    let level, job, guild, mureung, union; // 긁어올 데이터들
    if ($('#character-card > div > ul.character-card-summary > li:nth-child(3) > span')) { // 레벨 데이터
      level = $('#character-card > div > ul.character-card-summary > li:nth-child(3) > span').text();
    }
    else {
      level = "NONE";
    }
    if ($('#character-card > div > ul.character-card-summary > li:nth-child(5) > span')) { // 직업 데이터
      job = $('#character-card > div > ul.character-card-summary > li:nth-child(5) > span').text();
    }
    else {
      job = "NONE";
    }
    if ($('#character-card > div > div:nth-child(3) > span')){
      guild = $('#character-card > div > div:nth-child(3) > span').text();
    }
    else {
      guild = "NONE";
    }
    if ($('#character-card > div > ul.character-card-additional > li:nth-child(1) > span')) { // 무릉도장 데이터
      mureung = $('#character-card > div > ul.character-card-additional > li:nth-child(1) > span').text();
    }
    else {
      mureung = "NONE";
    }     
    if ($('#character-card > div > ul.character-card-additional > li:nth-child(2) > small')) { // 유니온 데이터
      union = $('#character-card > div > ul.character-card-additional > li:nth-child(2) > small').text();
      if (union === "") {
        union = "정보없음";
      }
    }
    else {
      union = "NONE";
    }
    data.push([i, level, job, guild, mureung, union]); // 행 위치와 데이터를 함께 저장
    Utilities.sleep(1000); // 스크래핑 사이에 딜레이 삽입
  }
  setSheetData(sheet, data) // 데이터 한번에 입력
}

...

  • let data = []; : 추후 setSheetData()로 넘겨줄 캐릭터 데이터입니다. 행의 위치와 캐릭터 데이터 등이 담길 예정입니다.
  • Utilities.sleep(1000): 서버에 무리를 주지 않기 위해 스크래핑 사이에 딜레이를 삽입합니다.
  • setSheetData(): 데이터를 시트에 쓰는 함수이며 바로 다음 차례에 구현합니다.

3) 데이터 시트에 쓰기

마찬가지로 앞서 작성했던 setSheetData()를 완성해보겠습니다.
setValues() 함수를 이용합니다.


GetMapleData.gs - setSheetData()


...

function setSheetData(sheet, data){
  var colPos = 2; // 입력을 시작할 열의 위치
  var numRows = 1; // 입력할 행의 개수(범위)
  var numCols = 5; // 입력할 열의 개수(범위) 
  for (var datum of data) { 
    var input = []; // 입력할 정보만 따로 추출
    var rowPos = datum[0]; // 입력을 시작할 행의 위치 추출
    input.push(datum.slice(1)); // rowPos를 제외한 정보를 추출하고,setValues() 인자에 맞게 변환
    console.log(input);
    sheet.getRange(rowPos, colPos, numRows, numCols).setValues(input); // setValues()가 object[][]를 인수로 받음
  }
}

  • sheet.getRange(rowPos, colPos, numRows, numCols).setValues(input): 시트 입력 범위를 지정해 데이터를 한번에 입력합니다.

이제 GAS를 사용해 자동으로 maple.gg에서 데이터를 긁어올 수 있습니다.

3. 트리거 설정하기

트리거에 대한 자세한 설명은
트리거 사용하기
글 링크로 대체합니다.

메이플의 캐릭터 데이터는 오전 3~4시경에 1번만 갱신되므로 일 단위 타이머, 실행 시간은 정기 점검 시간(보통 오전 10시까지)을 고려하여 오전 11시~정오 사이로 설정합니다.

이제 트리거를 통해 손 대지 않더라도 자동으로 데이터가 갱신되는 명부가 완성되었습니다.

4. 다음 단계 고민

maple.gg 정보 갱신 버튼을 매일 눌러야 제대로 데이터 갱신이 될텐데, 이건 어떻게 자동화할까? 🤔

5. Reference

apps script refenrence - setValues
GAS Cheerio 라이브러리

좋은 웹페이지 즐겨찾기