DEV Community

Cover image for JavaScript로 웹 테이블 내보내기 자동화하기: 실용 가이드
circobit
circobit

Posted on

JavaScript로 웹 테이블 내보내기 자동화하기: 실용 가이드

웹사이트에서 테이블을 찾았습니다. 스프레드시트에 그 데이터가 필요합니다. 복사, 붙여넣기, Excel에서 정리하는 방법은 한 번은 괜찮습니다. 하지만 매주 이 데이터가 필요하다면? 또는 50개의 다른 페이지에서 가져와야 한다면?

이 가이드에서는 JavaScript로 HTML 테이블을 프로그래밍 방식으로 추출하고, 단순한 접근법을 깨뜨리는 엣지 케이스를 처리하고, 도구가 실제로 받아들이는 형식으로 내보내는 방법을 보여줍니다.

단순한 접근법 (그리고 왜 실패하는지)

가장 간단한 추출은 이렇게 생겼습니다:

function extractTable(table) {
  return Array.from(table.rows).map(row => 
    Array.from(row.cells).map(cell => cell.textContent.trim())
  );
}
Enter fullscreen mode Exit fullscreen mode

간단한 테이블에서는 작동합니다. 다음을 만나면 즉시 깨집니다:

  • Rowspan/colspan — 여러 행이나 열에 걸치는 셀
  • 중첩 테이블 — 테이블 셀 안의 테이블
  • 숨겨진 콘텐츠<style>, <script>, 또는 display:none 요소
  • 특수 문자 — 셀 내용의 줄바꿈, 탭, 따옴표

하나씩 수정해 보겠습니다.

Rowspan과 Colspan 처리

셀에 rowspan="2"가 있으면 현재 행과 다음 행에 공간을 차지합니다. 단순한 추출기는 예상보다 적은 셀을 보고 열을 잘못 정렬합니다.

해결책: 차지된 위치를 추적하는 가상 그리드를 구축합니다.

function extractTableMatrix(table) {
  const rows = Array.from(table.rows);
  const grid = [];

  rows.forEach((rowEl, rowIndex) => {
    if (!grid[rowIndex]) grid[rowIndex] = [];

    let colIndex = 0;

    Array.from(rowEl.cells).forEach(cell => {
      // 이전 rowspan이 차지한 열 건너뛰기
      while (grid[rowIndex][colIndex] !== undefined) {
        colIndex++;
      }

      const text = cell.textContent.trim();
      const rowSpan = parseInt(cell.rowSpan, 10) || 1;
      const colSpan = parseInt(cell.colSpan, 10) || 1;

      // 이 셀이 차지하는 직사각형 블록 채우기
      for (let r = 0; r < rowSpan; r++) {
        const targetRow = rowIndex + r;
        if (!grid[targetRow]) grid[targetRow] = [];

        for (let c = 0; c < colSpan; c++) {
          const targetCol = colIndex + c;
          if (grid[targetRow][targetCol] === undefined) {
            grid[targetRow][targetCol] = text;
          }
        }
      }

      colIndex += colSpan;
    });
  });

  return grid;
}
Enter fullscreen mode Exit fullscreen mode

이제 이런 테이블이:

<table>
  <tr><td rowspan="2">A</td><td>B</td></tr>
  <tr><td>C</td></tr>
</table>
Enter fullscreen mode Exit fullscreen mode

올바르게 변환됩니다:

[
  ["A", "B"],
  ["A", "C"]  // "A"가 양쪽 행에 나타남
]
Enter fullscreen mode Exit fullscreen mode

깨끗한 텍스트 추출

textContent는 모든 것을 가져옵니다—일부 페이지가 테이블 셀에 삽입하는 <style> 태그의 CSS 규칙과 <script> 태그의 JavaScript도 포함합니다.

깨끗한 추출을 위해서는 필터링이 필요합니다:

function extractCellText(cell) {
  if (!cell) return "";

  // DOM 수정 방지를 위해 복제
  const clone = cell.cloneNode(true);

  // 보이지 않는 요소 제거
  const invisibleSelectors = "style, script, noscript, template, link";
  clone.querySelectorAll(invisibleSelectors).forEach(el => el.remove());

  // 공백 정규화
  return (clone.textContent || "").replace(/\s+/g, " ").trim();
}
Enter fullscreen mode Exit fullscreen mode

중첩 테이블 감지

테이블이 셀 안에 다른 테이블을 포함할 때, 보통은 외부 테이블의 데이터가 필요하지 재귀적인 혼란이 아닙니다.

감지는 간단합니다:

function isNestedTable(table, allTables) {
  let parent = table.parentElement;

  while (parent) {
    if (parent.tagName === "TABLE") {
      return true;  // 이 테이블은 다른 테이블 안에 있음
    }
    parent = parent.parentElement;
  }

  return false;
}

// 페이지 스캔 시 필터링
const allTables = document.querySelectorAll("table");
const topLevelTables = Array.from(allTables)
  .filter(t => !isNestedTable(t, allTables));
Enter fullscreen mode Exit fullscreen mode

CSV로 변환

CSV는 다음을 처리해야 할 때까지 단순해 보입니다:

  • 값 안의 쉼표
  • 값 안의 따옴표
  • 값 안의 줄바꿈

RFC 4180 준수 방식:

function toCSV(rows, delimiter = ",") {
  return rows.map(row =>
    row.map(cell => {
      if (cell == null) cell = "";
      const str = String(cell);

      // 구분자, 따옴표, 줄바꿈을 포함하면 따옴표로 감싸기
      const needsQuotes = str.includes(delimiter) || /["\r\n]/.test(str);
      const escaped = str.replace(/"/g, '""');

      return needsQuotes ? `"${escaped}"` : escaped;
    }).join(delimiter)
  ).join("\r\n");
}
Enter fullscreen mode Exit fullscreen mode

악몽 같은 케이스도 올바르게 처리합니다:

toCSV([['Say "Hello, World"', "Normal"]])
// '"Say ""Hello, World""",Normal'
Enter fullscreen mode Exit fullscreen mode

CSV 내보내기에 대한 완전한 가이드는 테이블을 Excel로 복사하기 위한 최고의 Chrome 확장 프로그램을 참조하세요.

JSON으로 변환

JSON 내보내기에서는 첫 번째 행이 키가 됩니다:

function toJSON(rows) {
  if (rows.length < 2) return "[]";

  const headers = rows[0].map((h, i) => sanitizeKey(h, i));
  const dataRows = rows.slice(1);

  const objects = dataRows.map(row => {
    const obj = {};
    headers.forEach((key, i) => {
      obj[key] = row[i] ?? "";
    });
    return obj;
  });

  return JSON.stringify(objects, null, 2);
}

function sanitizeKey(header, index) {
  let key = (header || "").toString().trim();

  if (!key) return `col_${index + 1}`;

  // 소문자 snake_case로 정규화
  return key
    .normalize("NFD")
    .replace(/[\u0300-\u036f]/g, "")  // 악센트 제거
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "_")
    .replace(/^_+|_+$/g, "");
}
Enter fullscreen mode Exit fullscreen mode

입력:

| Product Name | Price ($) |
|--------------|-----------|
| Widget       | 29.99     |
Enter fullscreen mode Exit fullscreen mode

출력:

[
  {
    "product_name": "Widget",
    "price": "29.99"
  }
]
Enter fullscreen mode Exit fullscreen mode

다운로드 트리거

브라우저 컨텍스트에서 서버 없이 다운로드를 트리거할 수 있습니다:

function downloadFile(content, filename, mimeType) {
  const blob = new Blob([content], { type: mimeType });
  const url = URL.createObjectURL(blob);

  const link = document.createElement("a");
  link.href = url;
  link.download = filename;
  link.click();

  URL.revokeObjectURL(url);
}

// 사용법
const csv = toCSV(extractTableMatrix(table));
downloadFile(csv, "data.csv", "text/csv;charset=utf-8");
Enter fullscreen mode Exit fullscreen mode

전체 조합

페이지의 첫 번째 테이블을 내보내는 간단한 북마클릿입니다:

javascript:(function(){
  const table = document.querySelector("table");
  if (!table) { alert("테이블을 찾을 수 없습니다"); return; }

  function extractTableMatrix(table) {
    const rows = Array.from(table.rows);
    const grid = [];
    rows.forEach((rowEl, ri) => {
      if (!grid[ri]) grid[ri] = [];
      let ci = 0;
      Array.from(rowEl.cells).forEach(cell => {
        while (grid[ri][ci] !== undefined) ci++;
        const text = cell.textContent.trim();
        const rs = parseInt(cell.rowSpan) || 1;
        const cs = parseInt(cell.colSpan) || 1;
        for (let r = 0; r < rs; r++) {
          if (!grid[ri+r]) grid[ri+r] = [];
          for (let c = 0; c < cs; c++) {
            if (grid[ri+r][ci+c] === undefined) grid[ri+r][ci+c] = text;
          }
        }
        ci += cs;
      });
    });
    return grid;
  }

  const data = extractTableMatrix(table);
  const csv = data.map(row => 
    row.map(c => c.includes(",") ? `"${c}"` : c).join(",")
  ).join("\n");

  const blob = new Blob([csv], {type: "text/csv"});
  const link = document.createElement("a");
  link.href = URL.createObjectURL(blob);
  link.download = "table.csv";
  link.click();
})();
Enter fullscreen mode Exit fullscreen mode

브라우저 확장 프로그램을 사용해야 할 때

이 코드는 작동하지만, 다양한 사이트에서 유지하는 것은 번거롭습니다. 테이블을 정기적으로 추출한다면 브라우저 확장 프로그램이 다음을 처리합니다:

  • 페이지당 여러 테이블
  • 형식 선택 (CSV, JSON, Excel)
  • 데이터 정제 (숫자 정규화, null 처리)
  • 열 선택 및 재정렬

저는 정확히 이 워크플로를 위해 HTML Table Exporter를 만들었습니다. 핵심 알고리즘은 여기 보여드린 것과 비슷하며, 사용하기 쉬운 UI로 패키징했습니다.

gauchogrid.com/ko/html-table-exporter에서 자세히 알아보거나 Chrome 웹 스토어에서 무료로 사용해 보세요.


테이블 추출 엣지 케이스에 대해 궁금한 점이 있으신가요? 댓글을 남겨주세요. 아마 이미 겪어봤을 겁니다.

Top comments (0)