통계나 가격표처럼 웹페이지에 있는 표를 엑셀로 옮길 때, 마우스로 드래그해 붙여넣으면 병합된 칸이 어긋나고 “51,780,579” 같은 숫자가 글자로 들어가 합계가 안 되는 경우가 많습니다. 그래서 웹 표를 엑셀로 저장하는 파이썬 스크립트를 이 노트북(윈도우 11, 파이썬 3.12)에서 직접 만들어 돌려봤습니다. 흔히 쓰는 방법은 pandas의 read_html() 한 줄이지만, 이 노트북에서는 pandas를 설치하자마자 “애플리케이션 제어 정책에서 이 파일을 차단했습니다” 오류로 import조차 되지 않았습니다. 결국 표준 라이브러리 HTML 파서와 openpyxl만으로 만들었고, 한국어 위키백과 “대한민국의 인구” 페이지(HTML 95만 자)의 표 25개를 엑셀 시트 25개로 저장하는 데 1.51초가 걸렸습니다. 그 과정에서 만난 403 오류, 병합 칸 밀림, 부동소수점 오차까지 순서대로 정리합니다.
먼저 시도한 pandas.read_html — 설치는 됐는데 실행이 막혔다
pandas가 있으면 pd.read_html(url)이 페이지의 표를 전부 DataFrame 목록으로 돌려주고, to_excel()로 바로 저장할 수 있습니다. 가장 짧은 방법이라 이것부터 시도했습니다. 이 노트북에는 pandas가 없어서 설치부터 했는데, 두 번 막혔습니다.
$ pip install pandas openpyxl
bash: .../Python312/Scripts/pip: Permission denied
$ python -m pip install pandas openpyxl (15.46초, pandas 3.0.6 설치됨)
$ python -X utf8 -c "import pandas"
File "pandas/_libs/missing.pyx", line 40, in init pandas._libs.missing
ImportError: DLL load failed while importing ops_dispatch: 애플리케이션 제어 정책에서 이 파일을 차단했습니다.
첫 번째는 pip 실행 파일을 직접 부를 때 난 Permission denied였습니다. python -m pip로 바꾸니 15초 만에 설치됐습니다. 두 번째가 진짜 문제였습니다. pandas는 속도를 위해 내부를 C로 컴파일한 DLL을 쓰는데, 이 노트북의 윈도우 앱 제어 정책이 그 DLL을 차단했습니다. 이 블로그 자동화를 운영하면서 같은 정책이 썸네일용 네이티브 모듈(@napi-rs/canvas)도 막은 적이 있어서, 회사 PC처럼 보안 정책이 걸린 환경에서는 충분히 생길 수 있는 일이라고 봤습니다.
참고로 이 오류 메시지는 처음에 ���ø����̼� ���� ��å����처럼 깨져서 보였습니다. 파이썬을 -X utf8 옵션으로 실행하자 한글로 제대로 읽혔습니다. 오류 메시지가 깨져 보이면 이 옵션부터 붙여 보는 게 원인 파악에 빠릅니다.
반면 엑셀 파일을 쓰는 openpyxl은 순수 파이썬이라 import openpyxl이 문제없이 됐습니다(3.1.5). 그래서 방향을 바꿨습니다. HTML 해석은 표준 라이브러리 html.parser, 저장은 openpyxl로 직접 만들기로 했습니다.
페이지 내려받기: User-Agent가 없으면 403
표준 라이브러리 urllib로 페이지를 받아오는 것부터 확인했습니다. 아무 헤더 없이 요청하니 위키백과가 바로 403을 돌려줬습니다.
$ python fetch_test.py <위키백과 URL> (User-Agent 지정 안 함)
HTTPError 403 Forbidden
0.47초
$ python fetch_test.py <위키백과 URL> "codetipbox-table-test/1.0"
200 text/html; charset=UTF-8 985,707바이트
table 태그 25
$ python -c "import urllib.request; print(urllib.request.build_opener().addheaders)"
[('User-agent', 'Python-urllib/3.12')]
urllib은 기본으로 Python-urllib/3.12라는 User-Agent를 보내는데, 위키백과는 이런 기본값 요청을 막습니다. 스크립트 이름과 연락처를 담은 User-Agent를 넣자 200과 함께 98만 바이트짜리 HTML이 내려왔고, 그 안에 <table 태그가 25개 있었습니다. 다른 사이트도 비슷한 이유로 막는 경우가 많으니, 403이 나면 User-Agent부터 확인하세요. 단, 사이트 이용약관이나 robots.txt가 수집을 금지한다면 헤더를 바꿔서 우회할 일이 아닙니다.
병합된 칸(rowspan·colspan)을 풀지 않으면 생기는 일
실제 업무 표에는 “지역”처럼 여러 행에 걸친 칸(rowspan)이나, “매출” 아래 8월·9월이 있는 칸(colspan)이 흔합니다. 이를 시험하려고 다음 HTML을 직접 만들었습니다. 각주 <sup>와 위키백과식 마이너스 기호(−)도 넣었습니다.
<html><head><meta charset="utf-8"><title>지점별 매출</title></head>
<body>
<table>
<caption>2026년 3분기 지점별 매출</caption>
<tr><th rowspan="2">지역</th><th rowspan="2">지점</th><th colspan="2">매출(원)</th><th rowspan="2">증감</th></tr>
<tr><th>8월</th><th>9월</th></tr>
<tr><td rowspan="2">서울</td><td>강남</td><td>12,500,000</td><td>13,100,000</td><td>4.8%</td></tr>
<tr><td>마포</td><td>8,200,000</td><td>7,950,000</td><td>−3.0%</td></tr>
<tr><td rowspan="2">부산</td><td>서면</td><td>9,800,000</td><td>10,050,000</td><td>2.6%</td></tr>
<tr><td>해운대<sup>[1]</sup></td><td>6,400,000</td><td>6,400,000</td><td>0.0%</td></tr>
<tr><td colspan="2">합계</td><td>36,900,000</td><td>37,500,000</td><td>1.6%</td></tr>
</table>
<p><sup>[1]</sup> 9월 중 리모델링 휴점 5일.</p>
</body></html>
먼저 셀을 나오는 순서대로만 담는 단순한 파서로 읽어 봤습니다.
"""rowspan/colspan을 무시하고 셀을 순서대로만 담는 단순 버전 (비교용)."""
import sys
from html.parser import HTMLParser
from pathlib import Path
class Naive(HTMLParser):
def __init__(self):
super().__init__()
self.rows, self.cell = [], None
def handle_starttag(self, tag, attrs):
if tag == 'tr':
self.rows.append([])
elif tag in ('td', 'th'):
self.cell = ''
def handle_endtag(self, tag):
if tag in ('td', 'th'):
self.rows[-1].append(self.cell.strip())
self.cell = None
def handle_data(self, data):
if self.cell is not None:
self.cell += data
p = Naive()
p.feed(Path(sys.argv[1]).read_text(encoding='utf-8'))
for row in p.rows:
print(row)
$ python naive.py sample.html
['지역', '지점', '매출(원)', '증감']
['8월', '9월']
['서울', '강남', '12,500,000', '13,100,000', '4.8%']
['마포', '8,200,000', '7,950,000', '−3.0%']
['부산', '서면', '9,800,000', '10,050,000', '2.6%']
['해운대[1]', '6,400,000', '6,400,000', '0.0%']
['합계', '36,900,000', '37,500,000', '1.6%']
결과가 그럴듯해 보이지만 엑셀에 그대로 넣으면 틀립니다. “마포” 행은 지역 칸이 비어 있어서 한 칸씩 왼쪽으로 밀렸고(지점 열에 매출이 들어감), 두 번째 머리글 행은 두 칸뿐이며, “해운대[1]”에는 각주 번호가 붙었습니다. HTML에서는 위 행의 rowspan이 그 칸을 차지하고 있어서 해당 <td> 자체가 없기 때문입니다. 표를 제대로 옮기려면 rowspan·colspan을 실제 칸으로 펼쳐야 합니다.
최종 스크립트: web_table_to_excel.py
표준 라이브러리와 openpyxl만 씁니다. 하는 일은 다음과 같습니다.
- URL이면 User-Agent를 붙여 내려받고, 파일이면 UTF-8 → CP949 순으로 읽습니다.
HTMLParser로<table>마다 행·칸·caption을 모읍니다.<sup>(각주),<script>,<style>안의 글자는 버립니다.to_grid()가 rowspan은 아래 행의 같은 위치에 값을 미리 예약해 두고, colspan은 옆 칸에 같은 값을 채워 빈틈없는 2차원 표로 만듭니다.to_value()가 “12,500,000”은 정수, “4.8%”는 0.048(엑셀 서식 0.0%), “−3.0%”는 -0.03으로 바꿉니다. 나머지는 글자 그대로 둡니다.- 표마다 시트를 하나씩 만들고, 첫 행 굵게·틀 고정·열 너비 자동 조정을 해서 저장합니다.
"""웹페이지(또는 저장한 HTML 파일)의 표를 엑셀로 저장합니다. 표준 라이브러리 + openpyxl.
사용법:
python web_table_to_excel.py <URL 또는 파일> --list
python web_table_to_excel.py <URL 또는 파일> 결과.xlsx [--match 글자] [--pick 0,3]
"""
import argparse
import re
import sys
import time
import urllib.request
from html.parser import HTMLParser
from pathlib import Path
from openpyxl import Workbook
from openpyxl.styles import Font
UA = 'Mozilla/5.0 (table-to-excel script; +https://codetipbox.com)'
SKIP_TAGS = {'style', 'script', 'sup'} # sup: 위키백과 각주 [1] 같은 것
class TableParser(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=True)
self.tables = [] # [{'caption': str, 'rows': [[(text, colspan, rowspan, is_th)]]}]
self.stack = [] # 중첩 표 대비
self.cell = None
self.skip = 0
self.in_caption = False
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag in SKIP_TAGS:
self.skip += 1
elif tag == 'table':
self.stack.append({'caption': '', 'rows': []})
elif not self.stack:
return
elif tag == 'caption':
self.in_caption = True
elif tag == 'tr':
self.stack[-1]['rows'].append([])
elif tag in ('td', 'th'):
self.cell = {'text': [], 'colspan': _int(a.get('colspan')), 'rowspan': _int(a.get('rowspan')), 'th': tag == 'th'}
elif tag == 'br' and self.cell is not None:
self.cell['text'].append(' ')
def handle_endtag(self, tag):
if tag in SKIP_TAGS:
self.skip = max(0, self.skip - 1)
elif tag == 'table' and self.stack:
self.tables.append(self.stack.pop())
elif tag == 'caption':
self.in_caption = False
elif tag in ('td', 'th') and self.cell is not None and self.stack:
rows = self.stack[-1]['rows']
if not rows:
rows.append([])
text = re.sub(r'\s+', ' ', ''.join(self.cell['text'])).strip()
rows[-1].append((text, self.cell['colspan'], self.cell['rowspan'], self.cell['th']))
self.cell = None
def handle_data(self, data):
if self.skip:
return
if self.cell is not None:
self.cell['text'].append(data)
elif self.in_caption and self.stack:
self.stack[-1]['caption'] += data.strip()
def _int(v):
try:
return max(1, int(str(v).strip()))
except (TypeError, ValueError):
return 1
def to_grid(rows):
"""colspan/rowspan을 풀어서 빈칸 없는 2차원 리스트로 만듭니다."""
grid, pending = [], {} # pending[(r, c)] = 위에서 rowspan으로 내려온 값
for r, row in enumerate(rows):
out, c = [], 0
cells = iter(row)
while True:
if (r, c) in pending:
out.append(pending.pop((r, c)))
c += 1
continue
cell = next(cells, None)
if cell is None:
break
text, colspan, rowspan, _ = cell
for dc in range(colspan):
out.append(text)
for dr in range(1, rowspan):
pending[(r + dr, c + dc)] = text
c += colspan
grid.append(out)
width = max((len(r) for r in grid), default=0)
return [r + [''] * (width - len(r)) for r in grid]
NUM = re.compile(r'^[+-]?\d{1,3}(,\d{3})+(\.\d+)?$|^[+-]?\d+(\.\d+)?$')
def to_value(text):
"""'51,780,579' -> 51780579, '-0.3' -> -0.3, 나머지는 글자 그대로."""
t = text.replace('−', '-') # 위키백과의 마이너스 기호(−)
if NUM.match(t):
t = t.replace(',', '')
return float(t) if '.' in t else int(t)
if t.endswith('%') and NUM.match(t[:-1]):
return round(float(t[:-1].replace(',', '')) / 100, 10) # 2.6/100 = 0.026000000000000002 방지
return text
def display_len(s):
"""한글·한자는 영문 두 글자 폭으로 셉니다."""
return sum(2 if ord(ch) >= 0x1100 else 1 for ch in s)
def load(src):
if re.match(r'https?://', src):
req = urllib.request.Request(src, headers={'User-Agent': UA})
with urllib.request.urlopen(req, timeout=30) as res:
charset = res.headers.get_content_charset() or 'utf-8'
return res.read().decode(charset, errors='replace')
raw = Path(src).read_bytes()
for enc in ('utf-8', 'cp949'):
try:
return raw.decode(enc)
except UnicodeDecodeError:
pass
return raw.decode('utf-8', errors='replace')
def main():
ap = argparse.ArgumentParser()
ap.add_argument('src')
ap.add_argument('out', nargs='?')
ap.add_argument('--list', action='store_true', help='표 목록만 보기')
ap.add_argument('--match', help='이 글자가 들어간 표만')
ap.add_argument('--pick', help='번호로 고르기 (예: 0,3)')
ap.add_argument('--min-rows', type=int, default=2)
args = ap.parse_args()
t0 = time.perf_counter()
html = load(args.src)
t1 = time.perf_counter()
parser = TableParser()
parser.feed(html)
tables = []
for t in parser.tables:
grid = to_grid(t['rows'])
if len(grid) >= args.min_rows and grid and len(grid[0]) >= 2:
tables.append((t['caption'], grid))
t2 = time.perf_counter()
print(f'HTML {len(html):,}자, 표 {len(tables)}개 (불러오기 {t1 - t0:.2f}초, 분석 {t2 - t1:.2f}초)')
picked = list(enumerate(tables))
if args.match:
picked = [(i, t) for i, t in picked if any(args.match in cell for row in t[1] for cell in row) or args.match in t[0]]
if args.pick:
nums = {int(n) for n in args.pick.split(',')}
picked = [(i, t) for i, t in picked if i in nums]
if args.list or not args.out:
for i, (cap, grid) in picked:
head = ' | '.join(grid[0][:4])
print(f'[{i}] {len(grid)}행 x {len(grid[0])}열 {cap[:30]} 첫 줄: {head[:60]}')
return
if not picked:
sys.exit('고른 표가 없습니다. --list로 번호를 확인하세요.')
wb = Workbook()
wb.remove(wb.active)
for i, (cap, grid) in picked:
name = re.sub(r'[\\/*?:\[\]]', '', cap)[:20] or f'표{i}'
ws = wb.create_sheet(f'{i}_{name}'[:31])
for row in grid:
ws.append([to_value(v) for v in row])
for cell, text in zip(ws[ws.max_row], row):
if text.endswith('%') and isinstance(cell.value, float):
cell.number_format = '0.0%'
for cell in ws[1]:
cell.font = Font(bold=True)
ws.freeze_panes = 'A2'
for col in ws.columns:
longest = max(display_len(str(c.value or '')) for c in col)
ws.column_dimensions[col[0].column_letter].width = min(50, longest * 1.1 + 2)
print(f'시트 {ws.title}: {len(grid)}행 x {len(grid[0])}열')
wb.save(args.out)
print(f'저장: {args.out} ({Path(args.out).stat().st_size:,}바이트), 전체 {time.perf_counter() - t0:.2f}초')
if __name__ == '__main__':
main()
표가 여러 개인 페이지가 대부분이라 --list로 번호와 첫 줄을 먼저 보고, --pick 0,4로 번호를 고르거나 --match 글자로 특정 글자가 든 표만 고르게 했습니다. 행이 1개뿐이거나 열이 1개뿐인 표(레이아웃용 표가 흔함)는 기본으로 뺍니다.
테스트 표로 확인: 칸 밀림이 사라졌는지
앞의 sample.html을 엑셀로 저장한 뒤, openpyxl로 파일을 다시 열어 셀 값을 그대로 출력했습니다.
$ python web_table_to_excel.py sample.html sample.xlsx
HTML 765자, 표 1개 (불러오기 0.00초, 분석 0.00초)
시트 0_2026년 3분기 지점별 매출: 7행 x 5열
저장: sample.xlsx (5,362바이트), 전체 0.01초
$ python check_xlsx.py sample.xlsx 7 (엑셀을 다시 열어 셀 값 확인, 따옴표 표기만 정리)
['지역', '지점', '매출(원)', '매출(원)', '증감']
['지역', '지점', '8월', '9월', '증감']
['서울', '강남', 12500000, 13100000, 0.048]
['서울', '마포', 8200000, 7950000, -0.03]
['부산', '서면', 9800000, 10050000, 0.026]
['부산', '해운대', 6400000, 6400000, 0]
['합계', '합계', 36900000, 37500000, 0.016]
값 종류: {'str': 15, 'int': 11, 'float': 4}
“마포” 행 앞에 “서울”이 채워져 다섯 칸이 정확히 맞고, 매출은 정수(int), 증감은 소수(float)로 들어갔습니다. 엑셀에서 증감 열은 0.0% 서식이 걸려 4.8%로 보이고, 매출 열은 바로 SUM이 됩니다. 각주 [1]도 빠졌습니다.
다만 이 결과가 처음부터 나온 건 아닙니다. 중간에 두 가지를 고쳤습니다.
- 열 너비: 처음엔 글자 수에 1.6을 곱했더니 “해운대” 열 너비가 6.8로 나와 한글 세 글자가 다 안 들어갔습니다. 한글은 영문 두 글자 폭이라
display_len()에서 한글을 2칸으로 세도록 바꿨습니다. - 증감 열 너비가 24: 다른 열은 6~11인데 증감 열만 24로 넓어졌습니다. 확인해 보니 퍼센트를 100으로 나눌 때 생긴 부동소수점 오차 때문이었습니다.
$ python float_demo.py
4.8% -> 0.048 (round: 0.048)
2.6% -> 0.026000000000000002 (round: 0.026)
1.6% -> 0.016 (round: 0.016)
12.4% -> 0.124 (round: 0.124)
2.6을 100으로 나누면 0.026이 아니라 0.026000000000000002가 되고, 이 긴 숫자 길이로 너비를 계산했던 겁니다. 엑셀 화면에는 2.6%로 보이니 그냥 넘어가기 쉽지만, 값을 비교하거나 다른 프로그램으로 넘기면 문제가 될 수 있어 round(..., 10)으로 정리했습니다. 수정 후 증감 열 너비는 7.5가 됐습니다.
실제 페이지: 위키백과 표 25개를 1.51초에
이제 실제 페이지에 돌렸습니다. 먼저 목록부터 봅니다.
$ python web_table_to_excel.py "https://ko.wikipedia.org/wiki/대한민국의_인구" --list
HTML 950,447자, 표 25개 (불러오기 1.11초, 분석 0.17초)
[0] 13행 x 3열 연도별 인구 첫 줄: 연도 | 인구 | ±%
[1] 27행 x 5열 첫 줄: 연령 | 남자 | 여자 | 명
[4] 21행 x 9열 첫 줄: 연도 (년) | 추계인구(명) | 출생자수(명) | 사망자수(명)
[12] 22행 x 9열 첫 줄: | 2020 통계청 인구주택총조사 | 2020 통계청 인구주택총조사 | ...
[16] 292행 x 11열 첫 줄: | 가구주의 연령 | 2020 행정구역별 가구주의 연령 및 가구원수별 가구(일반가구) | ...
[20] 53행 x 3열 혼인건수, 조혼인율 첫 줄: 시점 | 혼인건수(건) | 조혼인율(천명당)
[24] 6행 x 3열 첫 줄: vte아시아의 인구 | vte아시아의 인구 | vte아시아의 인구
(일부 생략)
HTML 95만 자를 파싱해 표 25개를 찾는 데 0.17초, 내려받는 데 1.11초였습니다. 처음 시험할 때는 내려받기만 6.99초가 걸린 적도 있어서, 전체 시간은 대부분 네트워크가 좌우한다고 보면 됩니다. 필요한 표만 고르거나 전부 저장해 봤습니다.
$ python web_table_to_excel.py "<같은 URL>" population.xlsx --pick 0,4,12
HTML 950,447자, 표 25개 (불러오기 1.12초, 분석 0.17초)
시트 0_연도별 인구: 13행 x 3열
시트 4_표4: 21행 x 9열
시트 12_표12: 22행 x 9열
저장: population.xlsx (9,636바이트), 전체 1.31초
$ python web_table_to_excel.py "<같은 URL>" all.xlsx
HTML 950,447자, 표 25개 (불러오기 1.11초, 분석 0.19초)
(시트 25개)
저장: all.xlsx (62,216바이트), 전체 1.51초
25개 시트를 모두 저장해도 1.51초, 파일은 62KB였습니다. 저장한 엑셀을 다시 열어 내용을 확인했습니다.
$ python check_xlsx.py pop0.xlsx 13 (일부 행만 발췌, 따옴표 표기만 정리)
== 0_연도별 인구 (13행 x 3열)
['연도', '인구', '±%']
[1930, 13900805, '—']
[1940, 15627186, 0.124]
[2020, 51829136, 0.067]
[2025, 51667000, -0.003]
['출처:', '출처:', '출처:']
== 16_표16 (292행 x 11열)
[None, '가구주의 연령', '2020 행정구역별 가구주의 연령 및 가구원수별 가구(일반가구)', ...]
[None, '가구주의 연령', '일반가구', '가구원수 1명', '가구원수 2명']
['전국', '합계', 20926710, 6643354, 5864525]
['서울특별시', '합계', 3982290, 1390701, 1033901]
값 종류: {'NoneType': 1, 'str': 799, 'int': 2165, 'float': 236}
연도별 인구는 정수로, 12.4% 증가는 0.124로, 위키백과가 쓰는 유니코드 마이너스 “−0.3%”도 -0.003으로 제대로 들어갔습니다. 292행짜리 가구 통계 표도 rowspan으로 묶인 “서울특별시”가 각 행에 채워져 있어서, 엑셀에서 바로 필터나 피벗 테이블을 걸 수 있습니다. 이 표 안의 정수 셀 2,165개가 모두 숫자로 들어갔습니다.
결과를 쓰기 전에 확인할 것
자동으로 옮겨도 사람 손이 조금 필요한 부분이 있었습니다. 실제 결과에서 확인한 것만 정리합니다.
| 현상 | 실제 예 | 처리 |
|---|---|---|
| 머리글이 2줄인 표 | 표 16 첫 줄이 같은 제목으로 반복 | 첫 줄을 지우거나 두 줄을 합쳐 열 이름 만들기 |
| colspan 바닥글 | 연도별 인구 마지막 줄 “출처:” 3칸 복제 | 마지막 행 삭제 |
| 데이터 없음 표시 | “—”가 글자로 남음 | 숫자 열 계산 전에 빈칸으로 바꾸기 |
| 데이터가 아닌 표 | 표 24는 하단 내비게이션 상자 | –list로 보고 –pick으로 제외 |
| 페이지 수정 | 위키백과는 편집되면 표 번호·행 수가 바뀜 | 정기 실행이면 –pick 대신 –match 사용 |
- 자바스크립트로 나중에 그려지는 표는 이 방법으로 안 잡힙니다. 내려받은 HTML에
<table>이 실제로 있는지--list로 먼저 확인하세요. 0개라면 브라우저 자동화 도구가 필요합니다. - 로그인이 필요한 페이지는 브라우저에서 “다른 이름으로 저장”한 HTML 파일을 넣으면 됩니다. 스크립트는 URL 대신 파일 경로도 받습니다.
- 같은 사이트를 짧은 간격으로 반복 요청하지 마세요. 표 몇 개를 받는 데는 한 번 요청이면 충분합니다.
- 회사 PC처럼 보안 정책이 있는 환경에서는 pandas·numpy 같은 컴파일된 패키지가 막힐 수 있습니다. 순수 파이썬 패키지(openpyxl 등)와 표준 라이브러리 조합이 가장 확실하게 돌아갔습니다.
pandas를 쓸 수 있다면
보안 정책이 없는 PC라면 pandas가 더 짧습니다. pd.read_html()도 rowspan·colspan을 펼쳐 주고, to_excel()로 저장할 수 있습니다. 다만 이 노트북에서는 import가 막혀 실행해 보지 못했기 때문에 결과 비교는 하지 않았습니다. 이 글의 스크립트는 pandas가 막혔거나, 설치를 최소화하고 싶거나, 숫자·퍼센트 변환과 시트 서식을 직접 제어하고 싶을 때 쓰면 됩니다.
정리
| 항목 | 실측 결과 |
|---|---|
| pandas 3.0.6 | python -m pip로 15.46초에 설치, import는 앱 제어 정책에 차단 |
| 기본 urllib 요청 | 위키백과 403 Forbidden, User-Agent 지정 후 200 |
| 병합 칸 무시 | “마포” 행이 한 칸 왼쪽으로 밀림 |
| 최종 스크립트 | 테스트 표 7행 x 5열 정확히 복원, 숫자·퍼센트 변환 |
| 위키백과 “대한민국의 인구” | HTML 95만 자, 표 25개, 전체 저장 1.51초(62KB) |
가장 작은 첫걸음은 위 web_table_to_excel.py를 저장하고, 자주 보는 표가 있는 페이지 주소로 python web_table_to_excel.py 주소 --list를 한 번 돌려 보는 것입니다. 파일은 아무것도 만들어지지 않고 표 목록만 나오니, 원하는 번호를 확인한 뒤 결과.xlsx --pick 번호를 붙이면 됩니다.
다음 글에서는 JSON 파일을 명령줄에서 다루는 방법(jq 입문)을 다루겠습니다.