puppeteer을 이용하여 동적 크롤링하기 & 병렬 처리하기

김은호·2023년 8월 17일
1

개요

신입생 때 데이터 수집을 한다고 크롤링을 처음 접하였다. 당시에는 파이썬으로 진행하였는데 갑자기 크롤링을 해야할 일이 생기기도 하고 자바스크립트를 메인으로 씀에 따라 puppeteer로 크롤링을 진행해보았다.

엑셀 파일에서 논문의 타이틀을 읽고 그 타이틀을 사이트에서 검색하여 약어를 추출한다. 그런데 키워드로 검색을 하였을 때 논문이 두 개 이상 있거나 없다면 크롤링을 하지 않는다.

Puppeteer

NodeJS를 이용하여 Headless Chrome을 조작할 수 있는 라이브러리

Headless Browser: GUI가 없는 브라우저, GUI가 없어 cli로 웹을 조작하고 네트워크 통신을 구현할 수 있다.

npm install puppeteer

1. 엑셀에서 데이터 읽어오기

라이브러리를 이용하여 엑셀에서 데이터를 읽어온다.

npm install read-excel-file
let excelData = [];
  await readXlsxFile('./sheet.xlsx').then((rows) => {
    rows.forEach((row, i) => {
      if (i === 0) { // 0행은 타이틀 행이라 제외
        return;
      }
      const inputData = {
        number: row[0],
        title: row[1],
        jif: row[2],
      };
      excelData.push(inputData);
    });
  });

2. Chunk 단위로 데이터 나누기

크롤링을 병렬로 진행하면 성능이 더 좋을 것이다. 이를 위해 Chunk 단위로 데이터 리스트를 나누어준다.

const makeChunks = (arr, SIZE) => {
  const chunks = [];
  let s = 0;

  while (s < arr.length) {
    results.push(arr.slice(s, s + SIZE));
    s += SIZE;
  }
  return chunks;
};

결과로 2차원 배열이 리턴된다.

3. 데이터를 하나씩 검색하여 추출하기

async function crawlItem(browser, item) {
  const page = await browser.newPage(); // 새로운 페이지

  // 접속하는 url에 대한 header 설정
  await page.setExtraHTTPHeaders({
    'Accept-Language': 'en-US,en;q=0.9',
  });
  await page.setUserAgent(
    'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.181 Safari/537.36'
  );

  await page.goto('여기에 url을 입력', {
    waitUntil: 'networkidle2',
  });

  await page.type('#term', item);
  await page.click('#search');
  await page.waitForNavigation({ waitUntil: 'networkidle2' });

  // 검색한 결과가 하나일 경우
  const isMany = await page.evaluate(() => {
    const element = document.querySelector(
      '#maincontent > div > div:nth-child(3) > div > h3'
    );
    return element;
  });

  // 결과가 하나라면 진행
  if (isMany === null) {
    // $$eval === Array.from(document.querySelectorAll())
    const arr = await page.$$eval(
      '#maincontent > div > div:nth-child(5) > div > div.nlmcat_entry > dl dt',
      (ele) => {
        return ele.map((el) => {
          return el.textContent;
        });
      }
    );
    let result = arr.findIndex((ele) => ele.includes('NLM'));
    if (result >= 0) {
      result = 2 * result + 1;
      nlmIdx = result + 1;

      const data = await page.$eval(
        `#maincontent > div > div:nth-child(5) > div > div.nlmcat_entry > dl > dd:nth-child(${nlmIdx})`,
        (ele) => ele.textContent
      );

      await page.close();
      return data;
    }
  } else {
    await page.close();
  }
}

퍼퍼티어는 코드로 DOM 요소를 조작할 수 있다. 이 때 DOM 요소를 지정하는 방법으로는 CSS Selector을 이용한다.

// <input id='term' /> , <button id='search' />
await page.type('#term', item); // id: term인 DOM 요소에 item을 입력
await page.click('#search'); // id: search인 DOM 요소를 클릭
await page.waitForNavigation({ waitUntil: 'networkidle2' });

크롤링을 하려면 일단 그 데이터가 DOM 트리에 붙어야 한다. 이를 위해 퍼퍼티어에서는 그때까지 기다리도록 하는 여러 wait method가 있다. waitForNavigation은 페이지가 이동되었을 때 모든 요소가 렌더링이 될 때까지 대기하도록 지정한다.

evaluate와 $eval은 동작은 비슷하지만 큰 차이는 evaluate는 지정한 selector가 없을 때 null을 반환하지만 $eval은 error을 반환한다. $eval을 사용한다면 try-catch로 에러 핸들링을 해주자.

const data = await page.$eval(
        `#maincontent > div > div:nth-child(5) > div > div.nlmcat_entry > dl > dd:nth-child(${nlmIdx})`,
        (ele) => ele.textContent
      );

이처럼 퍼퍼티어로 DOM 요소를 selector로 지정하여 가져올 수 있다.

동작이 끝나면 page를 닫아준다.

4. Promise.all로 한꺼번에 처리하기

// data: 한 chunk
async function crawl(data) {
  const browser = await puppeteer.launch({
    headless: true, // false는 개발을 진행할 때 
  });
  const promises = data.map(async (item) => {
    const result = await crawlItem(browser, item.title);
    return {
      ...item,
      NLM: result,
    };
  });

  const results = await Promise.all(promises);
  await browser.close();
  return results;
}

결국 promises는 Promise 객체 배열이 되고, Promise.all로 병렬 처리를 해준다.

작업이 완료된다면 브라우저를 닫아준다.

마무리

전체코드

const puppeteer = require('puppeteer');
const readXlsxFile = require('read-excel-file/node');
const fs = require('fs');

// 청크 단위로 분할
const makeChunks = (arr, SIZE) => {
  const chunks = [];
  let s = 0;

  while (s < arr.length) {
    results.push(arr.slice(s, s + SIZE));
    s += SIZE;
  }
  return chunks;
};

async function crawl(data) {
  const browser = await puppeteer.launch({
    headless: true,
  });
  const promises = data.map(async (item) => {
    const result = await crawlItem(browser, item.title);
    return {
      ...item,
      NLM: result,
    };
  });

  const results = await Promise.all(promises);
  await browser.close();
  return results;
}

async function crawlIteminCurrent(browser, item) {}

async function crawlItem(browser, item) {
  const page = await browser.newPage();

  await page.setExtraHTTPHeaders({
    'Accept-Language': 'en-US,en;q=0.9',
  });
  await page.setUserAgent(
    'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.181 Safari/537.36'
  );

  await page.goto('url을 여기에 입력', {
    waitUntil: 'networkidle2',
  });

  await page.type('#term', item);
  await page.click('#search');
  await page.waitForNavigation({ waitUntil: 'networkidle2' });

  // 검색한 결과가 하나일 경우
  const isMany = await page.evaluate(() => {
    const element = document.querySelector(
      '#maincontent > div > div:nth-child(3) > div > h3'
    );
    return element;
  });

  if (isMany === null) {
    const arr = await page.$$eval(
      '#maincontent > div > div:nth-child(5) > div > div.nlmcat_entry > dl dt',
      (ele) => {
        return ele.map((el) => {
          return el.textContent;
        });
      }
    );
    let result = arr.findIndex((ele) => ele.includes('NLM Title'));
    if (result >= 0) {
      result = 2 * result + 1;
      nlmIdx = result + 1;

      const data = await page.$eval(
        `#maincontent > div > div:nth-child(5) > div > div.nlmcat_entry > dl > dd:nth-child(${nlmIdx})`,
        (ele) => ele.textContent
      );

      await page.close();
      return data;
    }
  } else {
    await page.close();
  }
}

async function main() {
  // 엑셀 데이터 읽기
  let excelData = [];
  await readXlsxFile('./sheet.xlsx').then((rows) => {
    rows.forEach((row, i) => {
      if (i === 0) {
        return;
      }
      const inputData = {
        number: row[0],
        title: row[1],
        jif: row[2],
      };
      excelData.push(inputData);
    });
  });

  const CHUNKSIZE = 6;
  const chunkList = makeChunks(excelData, CHUNKSIZE);
  let i = 0;
  let resultArr = [];
  for (chunk of chunkList) {
    const product = await crawl(chunk);
    resultArr.push(...product);
  }

  const realData = resultArr.filter((data) => data.NLM !== undefined);
  const jsonDataToString = JSON.stringify(realData);
  fs.writeFileSync('./dataToJSon.json', jsonDataToString);
}

main();

사이트가 이상한 것인지는 모르겠는데 검색을 진행하고 그 창에서 다시 검색을 진행하려고 하니 데이터가 안나왔다. 그래서 계속 껐다가 키는 방식을 사용해야만 했는데 이때문에 속도 저하가 많이 있다.. ㅠㅠ

또한 headless를 true로 했을 때 데이터가 밀리거나 잘못 나오는 경우가 있는데 차근차근히 해결해나가도록 하자.

1개의 댓글

comment-user-thumbnail
2023년 8월 17일

글 잘 봤습니다.

답글 달기