웹사이트에서 테이블을 찾았습니다. 스프레드시트에 그 데이터가 필요합니다. 복사, 붙여넣기, Excel에서 정리하는 방법은 한 번은 괜찮습니다. 하지만 매주 이 데이터가 필요하다면? 또는 50개의 다른 페이지에서 가져와야 한다면?
이 가이드에서는 JavaScript로 HTML 테이블을 프로그래밍 방식으로 추출하고, 단순한 접근법을 깨뜨리는 엣지 케이스를 처리하고, 도구가 실제로 받아들이는 형식으로 내보내는 방법을 보여줍니다.
단순한 접근법 (그리고 왜 실패하는지)
가장 간단한 추출은 이렇게 생겼습니다:
function extractTable(table) {
return Array.from(table.rows).map(row =>
Array.from(row.cells).map(cell => cell.textContent.trim())
);
}
간단한 테이블에서는 작동합니다. 다음을 만나면 즉시 깨집니다:
- 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;
}
이제 이런 테이블이:
<table>
<tr><td rowspan="2">A</td><td>B</td></tr>
<tr><td>C</td></tr>
</table>
올바르게 변환됩니다:
[
["A", "B"],
["A", "C"] // "A"가 양쪽 행에 나타남
]
깨끗한 텍스트 추출
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();
}
중첩 테이블 감지
테이블이 셀 안에 다른 테이블을 포함할 때, 보통은 외부 테이블의 데이터가 필요하지 재귀적인 혼란이 아닙니다.
감지는 간단합니다:
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));
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");
}
악몽 같은 케이스도 올바르게 처리합니다:
toCSV([['Say "Hello, World"', "Normal"]])
// '"Say ""Hello, World""",Normal'
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, "");
}
입력:
| Product Name | Price ($) |
|--------------|-----------|
| Widget | 29.99 |
출력:
[
{
"product_name": "Widget",
"price": "29.99"
}
]
다운로드 트리거
브라우저 컨텍스트에서 서버 없이 다운로드를 트리거할 수 있습니다:
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");
전체 조합
페이지의 첫 번째 테이블을 내보내는 간단한 북마클릿입니다:
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();
})();
브라우저 확장 프로그램을 사용해야 할 때
이 코드는 작동하지만, 다양한 사이트에서 유지하는 것은 번거롭습니다. 테이블을 정기적으로 추출한다면 브라우저 확장 프로그램이 다음을 처리합니다:
- 페이지당 여러 테이블
- 형식 선택 (CSV, JSON, Excel)
- 데이터 정제 (숫자 정규화, null 처리)
- 열 선택 및 재정렬
저는 정확히 이 워크플로를 위해 HTML Table Exporter를 만들었습니다. 핵심 알고리즘은 여기 보여드린 것과 비슷하며, 사용하기 쉬운 UI로 패키징했습니다.
gauchogrid.com/ko/html-table-exporter에서 자세히 알아보거나 Chrome 웹 스토어에서 무료로 사용해 보세요.
테이블 추출 엣지 케이스에 대해 궁금한 점이 있으신가요? 댓글을 남겨주세요. 아마 이미 겪어봤을 겁니다.
Top comments (0)