へっぽこ博士のなんちゃって研究室

PythonやR、分子軌道計算等に関する記事を書きます

R : 原子量表のスクレイピング

0.事始め

原子量一覧が欲しかったので、日本化学会にて公開されているものからスクレイピングでデータフレームを構築しようとしたら、結構苦労したので、Rのソースコードを晒しておきます。Rにおける文字列処理方法の勉強にはなったと思います。apply関数?知らない子ですね。

1.ゴリ押し大作戦

pdftoolsパッケージを使って6~7ページを読み込むと、以下のようなデータが得られます。

"     1 水           素 H      1.008               44 ル  テ  ニ  ウ  ム Ru  101.1"                                   
"     2 ヘ   リ   ウ   ム He     4.003               45 ロ   ジ   ウ   ム Rh  102.9"                                  
"                                      3 リ   チ   ウ   ム Li     6.94                46 パ  ラ  ジ  ウ  ム Pd  106.4"
"     4 ベ  リ  リ  ウ  ム Be     9.012               47       銀       Ag  107.9"                                    
"     5 ホ     ウ     素 B     10.81                48 カ  ド  ミ  ウ  ム Cd  112.4"                                  
"     6 炭           素 C     12.01                49 イ  ン  ジ  ウ  ム In  114.8"

日本語の元素名が均等割り付けされているため、不要なスペースが紛れ込んでおり、そのままでは区切り文字で各要素を分割することができません。
こういった体裁だけのための書式は、データサイエンティストにとって、本当に面倒です。
とりあえず、以下の作戦でスクレイピングすることにしました。
・1 数字またはアルファベットの前後のスペースを、
  別の区切り文字に置き換える。
・2 余分なスペース、重複した区切り文字を除去。
・3 データフレームに入れて整形。

2.プログラム本体

で、最終的にこうなりました。

require(pdftools)
require(tidyverse) #stringr, tidyr, dplyr

# check multiple-byte character : return bool
is_multiple <- function(char) {
  pattern <- "\\p{Block=Katakana}|\\p{Script=Han}"
  invisible(return(str_detect(char,pattern=pattern)))
}

# check digit, alphabet and period : return bool
is_alnum <- function(char) {
  if (is_multiple(char)) invisible(return(FALSE))

  pattern <- "[[:alnum:]]|\\."
  invisible(return(str_detect(char,pattern=pattern)))
}

# check blank : return bool
is_blank <- function(char) {
  if (is_multiple(char)) invisible(return(FALSE))

  pattern <- "[[:blank:]]"
  invisible(return(str_detect(char, pattern=pattern)))
}

# extract character at a specified position : return character
chr_sub <-function(string, locate) {
  invisible(return(str_sub(string, locate, locate)))
}

# replace character at a specified position : return string
chr_replace <- function(string, locate, char) {
  forward <- str_sub(string, 1, (locate-1))
  backward <- str_sub(string, (locate+1), -1)
  invisible(return(str_c(forward,char,backward)))
}

# rebuild string : return data.frame
rebuild_str <- function(string) {
  new_str <- str_trim(string)
  N <- str_length(new_str)
  if (is.na(N)|(N < 2)) invisible(return(new_str))
  
  for (i in 2:(N-1)) {
    char <- chr_sub(new_str, i)
    if (is_blank(char)) {
      fchar <- chr_sub(new_str, (i-1))
      bchar <- chr_sub(new_str, (i+1))
      if (is_alnum(fchar)|is_alnum(bchar)) {
        new_str <- chr_replace(new_str, i, ",")
      }
    }
  }
  new_str <- str_replace_all(new_str, " ","" )
  sp_str <- str_split(new_str, "[,]+") %>% as.data.frame()
  invisible(return(t(sp_str)))
}

# extended bind_rows : return data.frame
bind_rows_ex <- function(df1, df2) {
  colnames(df2) <- colnames(df1)
  bind_df <- bind_rows(df1, df2)
  invisible(return(bind_df))
}

# rebuild page : return data.frame
rebuild_page <- function(source, page, start=1L, end=-1L) {
  if ((end==-1L)|(end > length(source[[page]]))) end <- length(source[[page]])

  txt <- source[[page]][start:end]
  txt.df <- data.frame(matrix(nrow=(end-start), ncol=8))
  for (i in 1:length(txt)) {
    txt.df[i,] <- rebuild_str(txt[i])
  }
  txt.df <- bind_rows_ex(txt.df[,1:4], txt.df[,5:8])
  invisible(return(txt.df))
}

# main process
fname <- "atom_2022.pdf"
suppressMessages(pdf_file <- pdf_text(fname))

pdftxt <- str_replace_all(pdf_file, c("*\n"="", "†\n"="", "("="", ")"=""))
pdftxt <- str_split(pdftxt, "\n", simplify = F)
p1 <- rebuild_page(pdftxt, 6, 12, 54) 
p2 <- rebuild_page(pdftxt, 7, 3, 18)

atom_table <- bind_rows(p1, p2)
colnames(atom_table) <- c("Number","Element","Symbol","Weight")
View(atom_table)


3.ソースコードの解説

is_multiple(char)
is_alnum(char)
is_blank(char)
文字種類の判定用関数です。
最初に全角、いわゆる2バイト文字の判定処理を入れないと、1バイト目の文字コードからアルファベットとして判断されることがあるようです。
長音符号(ー)はUnicodeではKatakanaブロックにあります。 解説サイトでは、片仮名の判定をスクリプト(Script, sc, または省略)で指定されているほうが多いようで、この場合は、長音符号が含まれないので注意が必要です。
今回の場合、漢字の範囲はScriptで十分足りますが、人名や地名を判定する必要がある場合は不十分です。 詳しくはこちらのサイト様が参考になると思います。

chr_sub(string, locate)
stringからlocateで指定した位置の文字を返すだけのラッパー関数です。

chr_replace(string, locate, char)
string中のlocateで指定した位置の文字をcharに置き換える関数です。意外とありそうでない関数の1つで、 pythonでも同様の質問がされていましたC言語だとポインタを渡して簡単にアクセスできるのに・・・

rebuild_str(string)
stringから空白を取り除き、要素に分解したデータフレームを返します。作戦1~2のメイン処理部分です。
日本語の元素名は不要という方は、下記のような実装でもいいかもしれません。

rebuild_str <- function(string) {
  new_str <- str_trim(string)
  N <- str_length(new_str)
  if (is.na(N)|(N<2)) invisible(return(new_str))

  for (i in 2:(N-1)) {
    char <- chr_sub(new_str, i)
    if (is_multiple(char)) {
      new_str <- str_remove(new_str, char)
    }
  }
  
  sp_str <- str_split(new_str, "[ ]+") %>% as.data.frame()
  invisible(return(t(sp_str)))
}


連続した区切り文字を1文字として扱うには、正規表現では"[ ]+"のように後ろに+を付けます。

bind_rows_ex(df1, df2)
2つのデータフレームを縦に結合するラッパー関数です。
dplyr::bind_rowsは、base::rbindと比較して、列数や列名が異なっていても結合できる便利な関数です。 ただ、思っていたのと挙動が違ったので、用意しました。 色々試行錯誤した結果、列名にNULLを入れて結合することに落ち着いたので、rbindで良かったような・・・

bind_rowsの挙動は、以下のような2つのデータフレームを結合すると、

AB
12

CD
34

こんな感じになります。

ABCD
12NANA
NANA34


こうなって欲しかったんですけどね・・・

A'B'
12
34


rebuid_page(source, page, start, end)
pdf_textで読み込んだデータ(source)の指定ページ(page)を処理する関数です。
startとendで読み込み開始行と終了行を指定します。 WindowsLinuxとで改行コードの違いからか、若干ずれるので、お手持ちの環境に合わせて調整ください。
データ処理部分をfor文で回していますが、sapply関数でも代替できます。 ただ、実行時間に差が出なかった(どちらも0.5秒程度)のと、後々の処理が面倒だったので、書き慣れている方で処理しました。 必ずしもapply系関数の方が優れているわけではないという一例ですね。

メイン処理部分
pdftools::pdf_textを使うと、"PDF error: Invalid Font Weight"という大量のエラーを吐くことがあります。 これは、内部で使用しているPopplerライブラリが原因のようなので、ヘルプにある通り、suppressMessagesで出力を抑制しています。

後は、必要に応じてwrite.csv等で出力しておくのも良いかもしれません。