QR Send

スマホとPCのChrome間で、URLやテキストをQRコード1回のペアリングで送り合える自作Chrome拡張。アカウント不要・無料。

使ってみる →

Seleniumでテーブル(表)データを抽出してCSVに保存する定石(Ruby)

Selenium・自動化

<table>要素の構造を素直に辿ることが出発点になる

管理画面などに表形式のデータが並んでいると、行数が多くて目視での確認が大変なので、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")

theadtbodyが省略されている表もあるため、これらが存在しない場合はtable直下のtrを全件取得してから、後述する方法でヘッダー行と本文行を自分で切り分けます。

行ごとにセルをmapで配列に変換する

行(tr)を1つずつ取り出したら、その中のセルをmapで配列にするのが基本パターンです。セルの並び順はDOM上の出現順そのままにしたいので、thtdを別々に取得して結合するのではなく、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

さらに厄介なのがcolspanrowspanによる結合セルです。結合セルをそのまま出力すると、見出しの列数と本文の列数がずれて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行目の内容が変わるまで短い間隔でポーリングするなど、状態変化を確認してから次の抽出に進む形にしたほうが、読み込み中の表を空振りで読んでしまう事故を防げます。

まとめ

  • <table>はthead/tbody/tr/thtdの入れ子構造として素直に辿り、行ごとにセルをmapで配列化する
  • ヘッダー行と本文行は、theadの有無や「全セルがthかどうか」で切り分け、見つからない場合は連番の列名で補う
  • セルの改行・空文字・colspan/rowspanによる結合は、正規化関数と列位置の追跡で吸収してから配列に詰める
  • 書き出しはRuby標準のCSVで十分だが、Excelで開く前提ならUTF-8のBOM付与を忘れない
  • ページングされた表は、1ページ分の抽出ロジックの外側にページ送りループを足し、全ページの行を貯めてから最後に1回だけ書き出す

CSVへの書き出し自体を、実行中に落ちても壊れないようにしたい場合は、一時ファイル経由でrenameする手法をこちらの記事にまとめてあるので合わせて参考にしてください。

コメント

タイトルとURLをコピーしました