QR Send

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

使ってみる →

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

Selenium・自動化

表を見た瞬間、マス目をそのまま取ろうとしてハマる

管理画面に表形式のデータがずらっと並んでいると、行数が多くて目視の確認だけでは疲れてしまいます。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上の出現順のままにしたいので、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行目の内容が変わるまで短い間隔でポーリングし、状態変化を確認してから次の抽出に進むほうが、読み込み中の表を空振りで読んでしまう事故を防げます。正直、ここはまだ対症療法のままです。

ではコードをまとめてみます

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

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

コメント

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