병합된 셀이 있는 테이블은 어디에나 있습니다. Wikipedia에서 사용합니다. 스포츠 통계 사이트에서 사용합니다. 재무 보고서에서 사용합니다.
그리고 이런 테이블은 제가 본 거의 모든 테이블 스크래퍼를 망가뜨립니다.
문제는 이것입니다: <tr>과 <td> 요소를 순회할 때, HTML에 있는 것을 얻게 됩니다—시각적으로 렌더링된 것이 아니라. rowspan="3"인 셀은 HTML에서 한 번 나타나지만 출력에서는 세 행을 차지합니다.
단순한 접근 방식은 조용히 실패합니다. 열이 어긋나고, 데이터가 누락되며, 원본 테이블과 전혀 다른 내보내기 결과를 얻게 됩니다.
이 문제를 해결하는 방법을 알아보겠습니다.
문제: HTML과 그리드의 불일치
병합된 셀이 있는 간단한 테이블을 생각해 보세요:
<table>
<tr>
<td rowspan="2">A</td>
<td>B</td>
</tr>
<tr>
<td>C</td>
</tr>
</table>
시각적 표현:
| A | B |
| | C |
하지만 행을 단순히 순회하면서 셀을 추출하면:
// 잘못된 접근 방식
table.querySelectorAll('tr').forEach(row => {
const cells = row.querySelectorAll('td');
console.log(cells.map(c => c.textContent));
});
// 출력: ["A", "B"], ["C"]
// 기대값: ["A", "B"], ["A", "C"]
2행에서 시각적으로 걸쳐 있는 "A"가 누락됩니다.
해결책: 매트릭스 구축
올바른 접근 방식은 2D 그리드를 미리 할당하고 span 속성에 따라 채우는 것입니다.
function extractTableMatrix(table) {
const rows = Array.from(table.rows);
const grid = [];
rows.forEach((rowEl, rowIndex) => {
if (!grid[rowIndex]) grid[rowIndex] = [];
const gridRow = grid[rowIndex];
let colIndex = 0;
// 이전 행의 rowspan으로 이미 차지된 열 건너뛰기
while (gridRow[colIndex] !== undefined) {
colIndex++;
}
const cells = Array.from(rowEl.cells);
cells.forEach((cell) => {
// 차지된 셀 지나기
while (gridRow[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 targetRowIndex = rowIndex + r;
if (!grid[targetRowIndex]) grid[targetRowIndex] = [];
for (let c = 0; c < colSpan; c++) {
const targetColIndex = colIndex + c;
if (grid[targetRowIndex][targetColIndex] === undefined) {
grid[targetRowIndex][targetColIndex] = text;
}
}
}
colIndex += colSpan;
});
});
return grid;
}
이제 출력이 시각적 테이블과 일치합니다:
const matrix = extractTableMatrix(table);
// [["A", "B"], ["A", "C"]]
중요한 이유: 실제 사례
Wikipedia 국가 테이블
Wikipedia는 병합된 셀을 광범위하게 사용합니다. 지역별 국가를 나열하는 테이블:
<tr>
<td rowspan="5">유럽</td>
<td>프랑스</td>
</tr>
<tr>
<td>독일</td>
</tr>
<!-- ... -->
적절한 처리 없이는 2-5행의 "유럽" 연관성을 잃게 됩니다.
스포츠 통계 사이트
FBREF 같은 사이트는 rowspan과 colspan을 모두 사용합니다. 선수 통계 테이블에는:
- colspan이 있는 그룹 헤더 (출전 시간 → 경기 | 선발 | 분)
- 선수 세부 정보에 걸친 rowspan이 있는 요약 행
단순한 스크래퍼는 알아볼 수 없는 결과를 내보냅니다. 매트릭스 기반 접근 방식은 구조를 보존합니다.
재무 보고서
분기 보고서에는 종종:
<td colspan="4">2024</td>
<!-- 그 다음: Q1, Q2, Q3, Q4 -->
연도 헤더가 네 개의 분기 열 모두에 전파되어야 합니다.
처리해야 할 엣지 케이스
중간의 빈 셀
때로는 셀이 진짜 비어 있습니다. 이것을 "span으로 아직 채워지지 않은 것"과 혼동하지 마세요.
// 같은 열 수로 정규화
const maxCols = grid.reduce((max, row) =>
Math.max(max, row ? row.length : 0), 0
);
const normalized = grid.map(row => {
const r = row || [];
const out = new Array(maxCols);
for (let i = 0; i < maxCols; i++) {
out[i] = r[i] != null ? r[i] : "";
}
return out;
});
중첩 테이블
셀 안의 테이블은 노이즈를 만듭니다. 대부분의 경우 중첩 테이블의 텍스트 콘텐츠만 필요하지, 재귀적 추출은 필요하지 않습니다.
function extractCellText(cell) {
const clone = cell.cloneNode(true);
// 중첩 테이블, 스크립트, 스타일 제거
clone.querySelectorAll("table, script, style, noscript")
.forEach(el => el.remove());
return clone.textContent.replace(/\s+/g, " ").trim();
}
Colspan + Rowspan 결합
알고리즘은 이것을 자연스럽게 처리합니다—그리드에서 더 큰 직사각형을 채우는 것뿐입니다.
<td rowspan="2" colspan="3">큰 셀</td>
같은 값으로 2×3 블록을 채웁니다.
도구를 사용할 때
처음부터 작성하는 것은 교육적이지만 시간이 많이 소요됩니다. 프로덕션 용도로는 다음을 고려하세요:
- HTML Table Exporter — 이러한 엣지 케이스를 자동으로 처리하는 브라우저 확장 프로그램
- Pandas read_html() — 작동하지만 복잡한 중첩 테이블에서는 어려움
- Beautiful Soup — 수동 span 처리 필요
전체 알고리즘
모든 엣지 케이스가 처리된 프로덕션 버전:
function extractCellText(cell) {
if (!cell) return "";
const clone = cell.cloneNode(true);
// 보이지 않는 콘텐츠를 포함하는 요소 제거
const invisibleSelectors = "style, script, noscript, template, link";
clone.querySelectorAll(invisibleSelectors).forEach(el => el.remove());
const text = clone.textContent || "";
return text.replace(/\s+/g, " ").trim();
}
function extractTableMatrix(table) {
const rows = Array.from(table.rows);
const grid = [];
rows.forEach((rowEl, rowIndex) => {
if (!grid[rowIndex]) grid[rowIndex] = [];
const gridRow = grid[rowIndex];
let colIndex = 0;
while (gridRow[colIndex] !== undefined) {
colIndex++;
}
const cells = Array.from(rowEl.cells);
cells.forEach((cell) => {
while (gridRow[colIndex] !== undefined) {
colIndex++;
}
const text = extractCellText(cell);
const rowSpan = parseInt(cell.rowSpan, 10) || 1;
const colSpan = parseInt(cell.colSpan, 10) || 1;
for (let r = 0; r < rowSpan; r++) {
const targetRowIndex = rowIndex + r;
if (!grid[targetRowIndex]) grid[targetRowIndex] = [];
const targetRow = grid[targetRowIndex];
for (let c = 0; c < colSpan; c++) {
const targetColIndex = colIndex + c;
if (targetRow[targetColIndex] === undefined) {
targetRow[targetColIndex] = text;
}
}
}
colIndex += colSpan;
});
});
// 모든 행을 같은 길이로 정규화
const maxCols = grid.reduce((max, row) =>
Math.max(max, row ? row.length : 0), 0
);
return grid.map(row => {
const r = row || [];
const out = new Array(maxCols);
for (let i = 0; i < maxCols; i++) {
out[i] = r[i] != null ? r[i] : "";
}
return out;
});
}
요약
| 접근 방식 | Span 처리 | 프로덕션 준비 |
|---|---|---|
| 단순 순회 | ❌ | ❌ |
| Span이 있는 매트릭스 | ✅ | ✅ |
| Pandas read_html | 부분적 | 경우에 따라 |
| 브라우저 확장 프로그램 | ✅ | ✅ |
핵심 통찰: 테이블을 중첩된 HTML 요소가 아닌 그리드로 생각하세요. 먼저 그리드를 구축한 다음 내보내세요.
Wikipedia의 특히 까다로운 테이블을 내보내는 방법에 대해 자세히 알아보려면 Wikipedia 테이블을 Excel로 내보내기 가이드를 참조하세요.
코드 없이 테이블을 추출해야 하나요? gauchogrid.com/ko/html-table-exporter에서 자세히 알아보거나 Chrome 웹 스토어에서 무료로 사용해 보세요.
Top comments (0)