表を見た瞬間、マス目をそのまま取ろうとしてハマる
管理画面に表形式のデータがずらっと並んでいると、行数が多くて目視の確認だけでは疲れてしまいます。Seleniumで巡回してCSVに落としたくなる場面、よくありますよね。ここで最初につまずきやすいのが、いきなり見た目のマス目をそのまま取ろうとしてしまうことです。
<table>の中には<thead>と<tbody>があります。それぞれの中に行の<tr>が並び、さらにその中に見出しセルの<th>かデータセルの<td>が並ぶ入れ子構造です。この構造を素直に辿る実装にしておくと、後々のヘッダー分離や正規化がやりやすくなります。
table = driver.find_element(:css, "table.report")
thead_rows = table.find_elements(:css, "thead tr")
tbody_rows = table.find_elements(:css, "tbody tr")
theadやtbodyが省略されている表もあるので、これらが存在しない場合はtable直下のtrを全件取得してから、後述する方法でヘッダー行と本文行を自分で切り分けます。
行ごとにセルをmapで配列にしてみましょう
行(tr)を1つずつ取り出したら、その中のセルをmapで配列にするのが基本パターンです。セルの並び順はDOM上の出現順のままにしたいので、XPathの./th|./tdでその行直下のセルを出現順にまとめて取得します。
def cells_of(tr)
tr.find_elements(:xpath, "./th|./td")
end
rows = tbody_rows.map { |tr| cells_of(tr).map(&:text) }
cells_ofを共通関数にしておくと、ヘッダー行にも本文行にも同じロジックを使い回せます。これ、意外と使えます。
ヘッダー行と本文行、どう見分けるか
theadがある表ならヘッダーの取得は簡単で、thead trから取った1行をそのまま見出しとして扱えば済みます。厄介なのはtheadがなく、最初のtrが見出しを兼ねているレイアウトです。この場合は、行の中のセルが全部thかどうかで見出し行か判定します。
def header_row?(tr)
cells = cells_of(tr)
cells.any? && cells.all? { |cell| cell.tag_name == "th" }
end
all_trs = table.find_elements(:tag_name, "tr")
header_tr, *body_trs = if all_trs.first && header_row?(all_trs.first)
all_trs
else
[nil, *all_trs] # 見出し行が判別できない場合は連番の列名を後段で振る
end
headers = header_tr ? cells_of(header_tr).map(&:text) : nil
ヘッダーが最終的に見つからなかった場合は、後述のCSV書き出し側でcol_1, col_2のような連番の列名を機械的に振っておきましょう。これでどんな表でも欠落なく最後まで処理を通せます。
空セル・結合セル・改行混じりのセルを正規化する
セルのテキストをそのままCSVに突っ込むと、空文字と未取得の区別がつかなくなったり、セル内の改行がCSVの改行と混ざって行がずれたりします。まずはセル単位の正規化関数を用意しましょう。
def normalize_cell(cell)
text = cell.text.to_s
text = text.gsub(/\r\n|\r|\n/, " ").squeeze(" ").strip
text.empty? ? nil : text
end
さらに厄介なのがcolspan・rowspanによる結合セルです。結合セルをそのまま出力すると、見出しの列数と本文の列数がずれてCSVとして読み込めなくなります。ここでは列位置を追跡しながら、結合分を同じ値で埋める方式にします。
def extract_body_rows(trs, col_count)
carry_over = Array.new(col_count) { [nil, 0] }
trs.map do |tr|
cell_queue = cells_of(tr)
row = Array.new(col_count)
(0...col_count).each do |col|
if carry_over[col][1] > 0
row[col] = carry_over[col][0]
carry_over[col][1] -= 1
next
end
cell = cell_queue.shift
break unless cell
colspan = [(cell.attribute("colspan") || "1").to_i, 1].max
rowspan = [(cell.attribute("rowspan") || "1").to_i, 1].max
value = normalize_cell(cell)
colspan.times do |offset|
target_col = col + offset
row[target_col] = value
carry_over[target_col] = [value, rowspan - 1] if rowspan > 1
end
end
row
end
end
carry_overは「その列に、あと何行分このセルの値を引き継ぐか」を保持する仕組みです。縦方向にまたがるセル(rowspan)が来たら、その列に値と残り行数を仕込み、次の行以降でセルが尽きるまで同じ値を差し込みます。単純な表なら不要ですが、集計行やグループ見出しがある表では効いてきます。
Ruby標準のCSVで書き出す(文字コードとBOMの注意点)
正規化済みの行が揃ったら、あとは標準ライブラリのCSVで書き出すだけです。
require 'csv'
def write_table_csv(path, headers, rows)
csv_body = CSV.generate(encoding: "UTF-8") do |csv|
csv << headers
rows.each { |row| csv << row }
end
File.open(path, "wb") do |file|
file.write("\xEF\xBB\xBF") # ExcelでUTF-8を正しく開くためのBOM
file.write(csv_body)
end
end
ポイントはBOM(\xEF\xBB\xBF)です。Windows版Excelで開く前提のCSVは、BOMなしのUTF-8だと日本語部分が文字化けすることがあります。BOMを先頭に1つ書き足すだけで、Excelは「これはUTF-8だ」と認識して正しく開いてくれます。逆にExcelを介さないなら、BOMはむしろ不要なノイズなので、出力先に応じて切り替えられるようにしておきましょう。
ページングされた表は、外側にループを足すだけ
表が複数ページに分かれている場合は、1ページ分の抽出処理はそのままに、外側にページ送りのループを足すだけで対応できます。全ページ分の行を一旦メモリに貯めてから、最後にまとめて1回だけCSVへ書き出すのがシンプルです。
def collect_all_pages(driver, col_count)
all_rows = []
loop do
table = driver.find_element(:css, "table.report")
body_trs = table.find_elements(:css, "tbody tr")
all_rows.concat(extract_body_rows(body_trs, col_count))
next_link = driver.find_elements(:css, "a.pagination-next").first
break if next_link.nil? || next_link.attribute("aria-disabled") == "true"
next_link.click
sleep 0.5 # 本来はページ内容が切り替わるまでポーリングするほうが確実
end
all_rows
end
ここでのsleepはあくまで簡易的な待機です。本来はページ送り後に表の1行目の内容が変わるまで短い間隔でポーリングし、状態変化を確認してから次の抽出に進むほうが、読み込み中の表を空振りで読んでしまう事故を防げます。正直、ここはまだ対症療法のままです。
ではコードをまとめてみます
関数ごとに分けて説明してきましたが、実際に使うときは1ファイルにまとめて使います。通しで書いておくので、コピペで大丈夫です。
require 'csv'
def cells_of(tr)
tr.find_elements(:xpath, "./th|./td")
end
def header_row?(tr)
cells = cells_of(tr)
cells.any? && cells.all? { |cell| cell.tag_name == "th" }
end
def normalize_cell(cell)
text = cell.text.to_s
text = text.gsub(/\r\n|\r|\n/, " ").squeeze(" ").strip
text.empty? ? nil : text
end
def extract_body_rows(trs, col_count)
carry_over = Array.new(col_count) { [nil, 0] }
trs.map do |tr|
cell_queue = cells_of(tr)
row = Array.new(col_count)
(0...col_count).each do |col|
if carry_over[col][1] > 0
row[col] = carry_over[col][0]
carry_over[col][1] -= 1
next
end
cell = cell_queue.shift
break unless cell
colspan = [(cell.attribute("colspan") || "1").to_i, 1].max
rowspan = [(cell.attribute("rowspan") || "1").to_i, 1].max
value = normalize_cell(cell)
colspan.times do |offset|
target_col = col + offset
row[target_col] = value
carry_over[target_col] = [value, rowspan - 1] if rowspan > 1
end
end
row
end
end
def write_table_csv(path, headers, rows)
csv_body = CSV.generate(encoding: "UTF-8") do |csv|
csv << headers
rows.each { |row| csv << row }
end
File.open(path, "wb") do |file|
file.write("\xEF\xBB\xBF") # ExcelでUTF-8を正しく開くためのBOM
file.write(csv_body)
end
end
def collect_all_pages(driver, col_count)
all_rows = []
loop do
table = driver.find_element(:css, "table.report")
body_trs = table.find_elements(:css, "tbody tr")
all_rows.concat(extract_body_rows(body_trs, col_count))
next_link = driver.find_elements(:css, "a.pagination-next").first
break if next_link.nil? || next_link.attribute("aria-disabled") == "true"
next_link.click
sleep 0.5
end
all_rows
end
# headers・col_countはここまでの「ヘッダー行と本文行」の手順で求めた値をそのまま使います
rows = collect_all_pages(driver, headers.size)
write_table_csv("table.csv", headers, rows)
実行してtable.csvが生成され、Excelで開いても日本語が文字化けせずに表示されたら成功です。
まとめ
- <table>は
thead/tbody/tr/th・tdの入れ子構造として素直に辿り、行ごとにセルをmapで配列化する - ヘッダー行と本文行は、
theadの有無や「全セルがthかどうか」で切り分け、見つからない場合は連番の列名で補う - セルの改行・空文字・
colspan/rowspanによる結合は、正規化関数と列位置の追跡で吸収してから配列に詰める - 書き出しはRuby標準の
CSVで十分ですが、Excelで開く前提ならUTF-8のBOM付与を忘れずに - ページングされた表は、1ページ分の抽出ロジックの外側にページ送りループを足し、全ページの行を貯めてから最後に1回だけ書き出す
CSVへの書き出し自体を、実行中に落ちても壊れないようにしたい場合は、一時ファイル経由でrenameする手法をこちらの記事にまとめてあるので、あわせて読んでみてください。


コメント