Notice
Recent Posts
Recent Comments
Link
«   2026/02   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
Archives
Today
Total
관리 메뉴

사고쳤어요

[node / ts] Puppeteer로 웹페이지 크롤링하기 (하스스톤 카드 정보) 본문

JavaScript

[node / ts] Puppeteer로 웹페이지 크롤링하기 (하스스톤 카드 정보)

kevinmj12 2025. 8. 3. 21:30

https://pptr.dev/

 

Puppeteer | Puppeteer

build

pptr.dev

 

Puppeteer는 Chrome 또는 Firefox를 제어할 수 있는 고급 API를 제공하는 JavaScript 라이브러리이다.

웹페이지를 크롤링을하는 라이브러리는 다양하게 존재하지만, JavaScript 또는 TypeScript를 통해 크롤링을 하기 위해서 위 라이브러리를 사용하게 되었다.

 

npm i puppeteer # Downloads compatible Chrome during installation.
npm i puppeteer-core # Alternatively, install as a library, without downloading Chrome.

 

위 명령어를 통해 라이브러리를 설치한 뒤 진행하면 된다.

 

크롤링

크롤링은 카드 게임 "하스스톤"의 카드 정보들을 크롤링 할 것이다.

 

https://hearthstone.blizzard.com/ko-kr/cards?set=legacy&viewMode=table

 

하스스톤 카드 라이브러리

최신 카드를 살펴보고 새로운 덱을 구상해보세요!

hearthstone.blizzard.com

크롤링할 카드 라이브러리 웹사이트이다.

"고전"이라는 검색 조건으로 "테이블 보기"를 통해 카드에 대한 정보들이 테이블로 나열되어있는 것을 볼 수 있다.

 

코드

import puppeteer from "puppeteer";

const BASE_URL =
  "https://hearthstone.blizzard.com/ko-kr/cards?set=legacy&viewMode=table";

먼저 puppeteer를 import해준 뒤 URL을 입력해준다.

 

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();

  console.log("페이지 열기...");
  await page.goto(BASE_URL, { waitUntil: "networkidle0" });

puppeteer.launch()를 통해 브라우저를 실행,

browser.newPage()를 통해 페이지를 실행한다.

page.goto(BASE_URL)을 통해 우리가 크롤링하고 싶은 웹페이지로 이동할 수 있으며 옵션들을 적용할 수 있다.

 

위 코드에서는 waitUntili: "networkidle0"이라는 옵션을 적용하였는데,

이는 500ms동안 네트워크 요청이 0개일 때 페이지 이동을 완료한다는 뜻이다.

 

이외에도 window.onlad 발생 시 완료되는 load, DOMContentLoaded 발생 시 완료되는 domcontentloaded 등의 옵션이 있다.

 

await page.waitForSelector(
  "table.CardTableLayout__CardTableView-sc-1si3xqh-1"
);

await page.screenshot({ path: "debug.png", fullPage: true });

page.waitForSelector()를 통해 원하는 요소가 로드될 때까지 대기한다.

물론 위에서 waitUntil 옵션을 통해 로드가 될 때까지 대기를 하고 있지만, 안정성을 위해 위 코드를 추가해주었다.

 

만약 waitUntil 옵션도 적용하지 않고, waitForSelector도 적용하지 않고 바로 크롤링을 진행하게 된다면

위와 같이 아무것도 로드되지 않은 페이지를 크롤링하여 아무 결과도 나오지 않을 수 있다.

 

추가로 page.screenshot()을 통해 현재 puppeteer에서 이동한 웹페이지 스크린샷을 저장할 수 있다.

이를 통해 원하는 요소가 다 로드되었는지 디버깅할 수 있다.

 

console.log("카드 정보 수집 중...");

const cards = await page.evaluate(() => {
const rows = document.querySelectorAll(
  "table.CardTableLayout__CardTableView-sc-1si3xqh-1 > tbody > tr"
);

const results: any[] = [];

rows.forEach((row) => {
  const name = row.querySelector(".name")?.textContent?.trim() || "";
  results.push({ name });
});

return results;
});

본격적으로 카드 정보를 수집할 차례이다. (쉬운 이해를 이해 이름만을 크롤링하였다.)

하스스톤 카드 라이브러리 페이지는 위와 같이 <table> <tbody> 아래에 <tr>, <tr> ... 형식으로 카드들이 나열되어 있다.

document.querySelectorAll(
  "table.CardTableLayout__CardTableView-sc-1si3xqh-1 > tbody > tr"
);

를 통해서 모든 카드들에 대한 정보를 저장해준 후 results를 리턴해준다.

 

전체 코드

import puppeteer from "puppeteer";

const BASE_URL =
  "https://hearthstone.blizzard.com/ko-kr/cards?set=legacy&viewMode=table";

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();

  console.log("페이지 열기...");
  await page.goto(BASE_URL, { waitUntil: "networkidle0" });

  // 카드 요소 로드까지 대기
  await page.waitForSelector(
    "table.CardTableLayout__CardTableView-sc-1si3xqh-1"
  );

  await page.screenshot({ path: "debug.png", fullPage: true });

  console.log("카드 정보 수집 중...");

  const cards = await page.evaluate(() => {
    const rows = document.querySelectorAll(
      "table.CardTableLayout__CardTableView-sc-1si3xqh-1 > tbody > tr"
    );

    const results: any[] = [];

    rows.forEach((row) => {
      const name = row.querySelector(".name")?.textContent?.trim() || "";
      results.push({ name });
    });

    return results;
  });

  console.log(cards);

  await browser.close();
})();

위와 같이 카드 이름들이 잘 크롤링되는 것을 확인할 수 있다.

이제 이름뿐만 아니라 카드의 모든 정보들을 크롤링해보자.

 

 

전체 코드

import puppeteer from "puppeteer";
import fs from "fs-extra";
import path from "path";
import { HearthstoneCard } from "@/cards";

const OUTPUT_JSON = path.join(__dirname, "../cards.json");

const BASE_URL =
  "https://hearthstone.blizzard.com/ko-kr/cards?set=legacy&viewMode=table";

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();

  await page.setViewport({
    width: 1920,
    height: 1080,
  });

  console.log("페이지 열기...");
  await page.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: 60000 });

  await page.waitForSelector(
    "table.CardTableLayout__CardTableView-sc-1si3xqh-1"
  );

  await page.screenshot({ path: "debug.png", fullPage: true });

  console.log("카드 정보 수집 중...");

  const cards = await page.evaluate(() => {
    const rows = document.querySelectorAll(
      "table.CardTableLayout__CardTableView-sc-1si3xqh-1 > tbody > tr"
    );

    const results: HearthstoneCard[] = [];

    rows.forEach((row) => {
      // 카드 이름
      const name = row.querySelector(".card .name")?.textContent?.trim() || "";

      // 마나
      const manaStr = row
        .querySelector(".iconNumeric .manaCost")
        ?.textContent?.trim();
      const mana = manaStr ? parseInt(manaStr) : null;

      // 직업
      let cardClass = "";
      const classIconDiv = row.querySelector(".ClassIconContainer .ClassIcon"); // 가장 안쪽의 ClassIcon div를 선택
      if (classIconDiv) {
        const classList = Array.from(classIconDiv.classList); // 클래스 목록을 배열로 변환
        const classKeyword = classList.find(
          (cls) =>
            ![
              "CircleIcon-fmr8yz-0",
              "ClassIcon-sc-1hgwqgj-0",
              "ClassControl__ItemIcon-jisfzz-1",
              "DtAzA",
              "ClassIcon",
            ].includes(cls)
        );
        if (classKeyword) {
          cardClass = classKeyword;
        }
      }

      // 공격력
      const attackElement = row.querySelector(".iconNumeric .attack");
      const attackStr = attackElement
        ? attackElement.textContent?.trim()
        : null;
      const attack =
        attackStr && attackStr !== "-" ? parseInt(attackStr) : null;

      // 생명력
      const healthElement = row.querySelector(".iconNumeric .health");
      const healthStr = healthElement
        ? healthElement.textContent?.trim()
        : null;
      const health =
        healthStr && healthStr !== "-" ? parseInt(healthStr) : null;

      // 종류, 종족, 속성
      let type = ""; // 종류 (하수인, 주문, 무기, 영웅, 장소)
      let minionType: string[] = []; // 종족 (야수, 용족, 악마...)
      let spellSchool: string[] = []; // 주문 속성 (암흑, 화염, 냉기...)

      const typeElement = row.querySelector(
        "h6.CardTableLayout__TitleText-sc-1si3xqh-5.loMGUg"
      );
      if (typeElement) {
        const typeText = typeElement.textContent?.trim() || "";
        const parts = typeText.split(" - ").map((p) => p.trim());

        // 종류(하수인, 주문, 무기, 영웅, 장소)
        type = parts[0];

        // 하수인인 경우, 종족
        if (type === "하수인" && parts.length > 1) {
          minionType = parts[1].split(",").map((r) => r.trim());
        }
        // 주문인 경우, 속성
        else if (type === "주문" && parts.length > 1) {
          spellSchool = parts[1].split(",").map((s) => s.trim());
        }
      }

      // 등급
      let rarity = "";
      const rarityElement = row.querySelector(
        "h6.CardTableLayout__RarityText-sc-1si3xqh-6"
      );

      if (rarityElement) {
        const rarityText = rarityElement.textContent?.trim() || "";
        if (rarityText === "무료") {
          rarity = "일반"; // "무료" => "일반"
        } else {
          rarity = rarityText;
        }
      }

      // 키워드
      const keywords: string[] = [];
      const keywordElements = row.querySelectorAll(
        "h6.CardTableLayout__KeywordText-sc-1si3xqh-7"
      );

      keywordElements.forEach((element) => {
        const keywordText = element.textContent?.trim();
        if (keywordText) {
          keywords.push(keywordText);
        }
      });

      results.push({
        packs: "고전",
        name,
        mana,
        class: cardClass,
        attack,
        health,
        type,
        rarity,
        keywords: keywords,
        minionType,
        spellSchool,
        imagePath: "",
      });
    });

    return results;
  });

  // JSON 저장
  await fs.writeJson(OUTPUT_JSON, cards, { spaces: 2 });

  await browser.close();
})();

 

[
  {
    "packs": "고전",
    "name": "룬벼리기",
    "mana": 1,
    "class": "deathknight",
    "attack": null,
    "health": null,
    "type": "주문",
    "rarity": "희귀",
    "keywords": [
      "시체"
    ],
    "minionType": [],
    "spellSchool": [
      "암흑"
    ],
    "imagePath": ""
  },
  {
    "packs": "고전",
    "name": "맹독 송장",
    "mana": 1,
    "class": "deathknight",
    "attack": 1,
    "health": 2,
    "type": "하수인",
    "rarity": "희귀",
    "keywords": [
      "전투의 함성"
    ],
    "minionType": [
      "언데드"
    ],
    "spellSchool": [],
    "imagePath": ""
  },
  ...
]

imagePath는 테이블 보기에서 제공되지 않아 추가적인 작업이 필요하지만, 나머지 모든 정보들 크롤링에 성공하였다.

 

'JavaScript' 카테고리의 다른 글

JavaScript 객체  (0) 2025.04.01
JavaScript 제어 흐름(Flow Control)  (0) 2025.03.27
JavaScript 연산자  (0) 2025.03.27
JavaScript 타입  (0) 2025.03.26
JavaScript 변수  (0) 2025.03.26