HTML 테이블 파싱은 실제 데이터를 만나기 전까지는 간단해 보입니다. 위키피디아 테이블에는 네비게이션 행이 있습니다. 금융 사이트는 복잡한 rowspan을 사용합니다. 스포츠 통계 사이트는 헤더를 두 단계로 중첩합니다.
수천 개의 다른 사이트에서 사용되는 테이블 추출 도구 HTML Table Exporter를 만든 후, 대부분의 파서를 깨뜨리는 엣지 케이스를 정리했습니다. 각각의 처리 방법을 살펴보겠습니다.
문제 1: Rowspan 확장
rowspan="3"인 셀은 현재 행과 다음 두 행에서 수직 공간을 차지합니다. row.cells를 단순하게 반복하면 열이 어긋납니다.
깨진 출력:
| 국가 | 2020 | 2021 | 2022 | <- 헤더
| 미국 | 100 | 200 | 300 | <- 예상
| 150 | 250 | 350 | <- "미국" 누락 (rowspan 계속)
해결 방법: 가상 그리드에서 차지된 위치를 추적합니다.
function expandRowspans(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 => {
// 다음 비어있는 열 찾기
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++) {
grid[targetRow][colIndex + c] = text;
}
}
colIndex += colSpan;
});
});
// 행 길이 정규화
const maxCols = Math.max(...grid.map(r => r.length));
return grid.map(row => {
const normalized = new Array(maxCols).fill("");
row.forEach((val, i) => normalized[i] = val ?? "");
return normalized;
});
}
핵심 인사이트: 가상 그리드가 진실의 원천입니다. DOM 셀은 그것을 채우기 위한 지시사항일 뿐입니다.
문제 2: 중첩 테이블
위키피디아 인포박스에는 종종 테이블 셀 안에 테이블이 있습니다. 재귀적 접근법은 쓸모없는 결과를 추출합니다:
<table>
<tr>
<td>국가</td>
<td>
<table> <!-- 중첩! -->
<tr><td>인구</td><td>3.3억</td></tr>
</table>
</td>
</tr>
</table>
감지 전략: 테이블의 상위 요소가 테이블인지 확인합니다.
function isNestedTable(table) {
let parent = table.parentElement;
while (parent) {
if (parent.tagName === "TABLE") {
return true;
}
parent = parent.parentElement;
}
return false;
}
// 페이지 스캔 시
function getTopLevelTables() {
const all = document.querySelectorAll("table");
return Array.from(all).filter(t => !isNestedTable(t));
}
그런데 중첩 테이블의 내용은?
외부 테이블의 경우, 중첩 테이블을 텍스트 콘텐츠로 평탄화합니다:
function extractCellText(cell) {
const clone = cell.cloneNode(true);
// 중첩 테이블 제거 (텍스트는 textContent를 통해 이미 포함됨)
clone.querySelectorAll("table").forEach(t => t.remove());
// 보이지 않는 요소 제거
clone.querySelectorAll("style, script").forEach(el => el.remove());
return (clone.textContent || "").replace(/\s+/g, " ").trim();
}
문제 3: 위키피디아 네비게이션 행
위키피디아 테이블은 종종 네비게이션 행으로 시작합니다:
| v t e 국가별 인구 목록 |
| 순위 | 국가 | 인구 |
| 1 | 중국 | 14억 |
"v t e" 행(보기/대화/편집 링크)은 데이터가 아니라 UI입니다. 이것을 헤더 행으로 처리하는 파서는 쓸모없는 결과를 만듭니다.
위키피디아 테이블 처리에 대한 실용 가이드는 테이블 내보내기를 위한 Chrome 최고의 확장 프로그램 5선을 참조하세요.
감지:
function isWikipediaNavRow(row) {
const firstCell = row[0] || "";
// 네비게이션 행의 일반적인 패턴
const patterns = [
/^v\s+t\s+e\s/i, // "v t e "
/^\s*v\s*\|\s*t\s*\|\s*e/i, // "v | t | e"
/^\[v\]\s*\[t\]\s*\[e\]/i // "[v] [t] [e]"
];
return patterns.some(p => p.test(firstCell));
}
function detectHeaderRowIndex(matrix) {
for (let i = 0; i < Math.min(3, matrix.length - 1); i++) {
if (isWikipediaNavRow(matrix[i])) {
return i + 1; // 헤더는 다음 행
}
}
return 0; // 기본값: 첫 번째 행이 헤더
}
문제 4: 타이틀 행 (전체 열에 걸침)
일부 테이블에는 전체 너비에 걸치는 타이틀 행이 있습니다:
<table>
<tr><td colspan="4">분기별 매출 (백만 달러)</td></tr>
<tr><td>Q1</td><td>Q2</td><td>Q3</td><td>Q4</td></tr>
<tr><td>100</td><td>120</td><td>115</td><td>130</td></tr>
</table>
rowspan 확장 후 첫 번째 행은 ["분기별 매출...", "분기별 매출...", ...]—같은 값이 반복됩니다.
감지:
function isTitleRow(row, nextRow) {
if (!row || !nextRow) return false;
const uniqueValues = new Set(row.filter(v => v.trim()));
const nextUniqueValues = new Set(nextRow.filter(v => v.trim()));
// 타이틀 행의 특성:
// 1. 고유 값이 하나만 있음 (colspan으로 반복)
// 2. 다음 행에는 여러 고유 값이 있음 (실제 헤더)
// 3. 단일 값이 긴 텍스트 (보통 30자 이상)
return (
uniqueValues.size === 1 &&
nextUniqueValues.size > 2 &&
row[0] && row[0].length > 30
);
}
문제 5: 그룹화된 열 헤더 (FBREF 스타일)
FBREF 같은 스포츠 통계 사이트는 2단계 헤더를 사용합니다:
| | | 출전 시간 | 성적 |
| 선수 | 국적 | MP | 선발 | 분 | 골 | 어시스트 | xG |
| 홀란드 | 노르웨이 | 35 | 33 | 2950| 36 | 8 | 32 |
첫 번째 행에는 그룹 이름이 있습니다. 두 번째 행에는 실제 열 이름이 있습니다. 둘 다 "헤더"입니다.
과제: colspan 확장 후 0번째 행은 이렇게 됩니다:
["", "", "출전 시간", "출전 시간", "출전 시간", "성적", "성적", "성적"]
감지 휴리스틱:
function isGroupHeaderRow(row, nextRow) {
if (!row || !nextRow || row.length !== nextRow.length) return false;
// 이웃과 같은 값을 가진 셀 수 세기
let repeatCount = 0;
for (let i = 1; i < row.length; i++) {
if (row[i] && row[i] === row[i-1]) repeatCount++;
}
const repeatRatio = repeatCount / (row.length - 1);
// 그룹 헤더 행은 보통 40% 이상 반복 값을 가짐
// 그리고 다음 행이 더 많은 고유 값을 가짐
const uniqueInRow = new Set(row.filter(v => v.trim())).size;
const uniqueInNext = new Set(nextRow.filter(v => v.trim())).size;
return repeatRatio > 0.4 && uniqueInNext > uniqueInRow;
}
그룹 + 하위 헤더 병합:
function mergeGroupAndSubHeaders(groupRow, subHeaderRow) {
return subHeaderRow.map((subHeader, idx) => {
const group = (groupRow[idx] || "").trim();
const sub = (subHeader || "").trim();
if (!group) return sub;
if (!sub) return group;
if (sub.toLowerCase() === group.toLowerCase()) return sub;
return `${group} - ${sub}`;
});
}
// 결과: ["선수", "국적", "출전 시간 - MP", "출전 시간 - 선발", ...]
문제 6: 수평으로 복제된 테이블
위키피디아 인구 테이블에는 종종 이런 구조가 있습니다:
| 순위 | 이름 | 인구 | 순위 | 이름 | 인구 |
| 1 | 도쿄 | 3700만 | 11 | 파리 | 1100만 |
| 2 | 델리 | 3200만 | 12 | 카이로 | 1000만 |
이것은 수직 공간을 절약하기 위해 두 열로 표시된 하나의 논리적 테이블입니다.
감지:
function detectHorizontalDuplication(headers) {
const half = Math.floor(headers.length / 2);
if (half < 2) return null;
const firstHalf = headers.slice(0, half);
const secondHalf = headers.slice(half, half * 2);
// 두 번째 절반이 첫 번째 절반과 일치하는지 확인
const matches = firstHalf.every((h, i) =>
h.toLowerCase() === secondHalf[i]?.toLowerCase()
);
if (matches) {
return { detected: true, repeatCount: 2, baseColumns: half };
}
return null;
}
정규화: 각 행을 분할하고 수직으로 쌓기:
function normalizeHorizontallyDuplicatedTable(matrix, baseColumns) {
const header = matrix[0].slice(0, baseColumns);
const normalizedRows = [header];
for (let i = 1; i < matrix.length; i++) {
const row = matrix[i];
// 첫 번째 절반
normalizedRows.push(row.slice(0, baseColumns));
// 두 번째 절반 (비어있지 않은 경우)
const secondHalf = row.slice(baseColumns, baseColumns * 2);
if (secondHalf.some(cell => cell.trim())) {
normalizedRows.push(secondHalf);
}
}
return normalizedRows;
}
통합 알고리즘
실제 파싱에서는 이 모든 케이스를 순서대로 확인해야 합니다:
function parseTable(table) {
// 1. rowspan/colspan을 가상 그리드로 확장
let matrix = expandRowspans(table);
// 2. 네비게이션/타이틀 행 감지 및 건너뛰기
const headerIndex = detectHeaderRowIndex(matrix);
if (headerIndex > 0) {
matrix = matrix.slice(headerIndex);
}
// 3. 그룹화된 헤더 처리 (FBREF 스타일)
const groupedHeaders = detectGroupedColumnHeaders(matrix);
if (groupedHeaders) {
const mergedHeaders = mergeGroupAndSubHeaders(matrix[0], matrix[1]);
matrix = [mergedHeaders, ...matrix.slice(2)];
}
// 4. 수평 복제 처리
const duplication = detectHorizontalDuplication(matrix[0]);
if (duplication) {
matrix = normalizeHorizontallyDuplicatedTable(matrix, duplication.baseColumns);
}
return matrix;
}
이 엣지 케이스 테스트
위의 모든 패턴은 실제 버그 리포트에서 나왔습니다. 각각에 대한 HTML 픽스처가 포함된 테스트 스위트를 유지하고 있습니다:
// 테스트: 위키피디아 스타일 네비게이션 행
const navRowHtml = `
<table>
<tr><td colspan="3">v t e 국가</td></tr>
<tr><td>순위</td><td>국가</td><td>인구</td></tr>
<tr><td>1</td><td>중국</td><td>14억</td></tr>
</table>
`;
const result = parseTable(parseHtml(navRowHtml));
assert(result[0][0] === "순위"); // 헤더 올바르게 식별
assert(result[1][1] === "중국"); // 데이터 올바르게 정렬
테스트 스위트에는 이 패턴들의 조합을 다루는 24개의 케이스가 있습니다. 새로운 버그 리포트가 새로운 테스트 케이스가 됩니다.
직접 사용해 보세요
테이블 추출을 만들고 있다면, 이 글이 디버깅 시간을 절약해주기를 바랍니다. 코드를 작성하지 않고 테이블만 내보내고 싶다면, HTML Table Exporter가 이 모든 케이스를 자동으로 처리합니다.
gauchogrid.com/ko/html-table-exporter에서 자세히 알아보거나 Chrome 웹 스토어에서 무료로 사용해 보세요.
파서를 깨뜨리는 테이블을 찾으셨나요? URL을 공유해 주세요. 저는 이런 엣지 케이스를 수집하고 있습니다.
Top comments (0)