はじめに
6月のMonthly Hackのテーマは「スクレイピング」。ということで、自分が普段から見ているWebサイトをスクレイピングすることにしました。
対象とするWebサイトは、音域データ! @ ウィキ ~この曲の最高音はどこ?~です。アーティスト毎に楽曲の地声最高音/最低音、裏声最高音/最低音などが表形式でまとめられています。
私はカラオケで曲を選ぶ際に重宝しているのですが、曲があいうえお順に並んでいるだけであり、「最高音がhiA以下の曲を表示」などの検索機能はありません。したがって、このページをスクレイピングして、自分で設定した条件*1に沿って検索をする事が目標です。
つまり、スクレイピングした情報から更に検索を行います。
本記事では
- python3でurllib.requestとBeautifulSoupを用いたスクレイピングの方法について解説します。
- スクレイピングと検索のソースコードを公開します。
- 問題点や改善の余地について考察します。
大まかな方法
流れは以下の通りです。
- 検索したいアーティスト、音域の条件を指定する。
- そのアーティストのWebページの全楽曲データを取得する。
- 音域の各条件に沿って、検索を行う。
- 各条件の検索結果に共通して現れる楽曲を出力する。
使用するモジュール
python3を用いているということで、使用するモジュールはurllib.requestとBeautifulSoupです*2。また、文字列処理の関係上、reとurllib.parseも使用します。
BeautifulSoupはpipでインストールする必要があります。
pip install beautifulsoup4
条件の指定
今回指定する条件は「アーティスト名」「地声の最高音」「地声の最低音」「裏声の最高音」の4つとしました。音の高さの表記は、こちらを参照してください。mid2AやhiBなどの表記を用います。
ここでの問題点は、アーティスト名の表記が正確でないと、対応するWebページのURLを取得できないことです。後々説明しますが、URLに正しいアーティスト名を埋め込むことによって、取得したいアーティストのページを指定することができます。ということで、できるのであれば表記揺れに対応したいところ。そこで、調査アーティスト一覧のページを利用しました。
このページのHTMLをurllib.requestで取得し、そこからBeautifulSoupでリンクのついているすべてのアーティスト名を取得します。そして、入力したアーティスト名と最も表記が近いアーティスト名を、編集距離*3を使って求めることを考えます。
指定したアーティストのWebページのスクレイピング
正確なアーティスト名を取得出来たら、アーティストのページを指定します。
上図の赤線の部分にアーティスト名を挿入します。あとは、urllib.requestでHTMLを取得し、BeautifulSoupで表の各行を一括で取得します。そして、後々のために、データをリスト型に格納します。
検索、出力
各条件(今回は「地声最高音」「地声最低音」「裏声最高音」の3つ)を満たしているかを曲ごとに確認し、満たしている場合は曲名を出力させます。そして、3つの出力結果に共通して現れる曲のみを最終出力としました。
ソースコード
ソースコードを公開します。各パラメータにお好みのアーティストや音の高さを入れてお試しください*4。
# coding: utf-8 import urllib.request import urllib.parse from bs4 import BeautifulSoup import re # パラメータの指定 word = 'ポルノグラフテー' natural_max_phone = 'hiA' natural_min_phone = 'mid2A' falsetto_max_phone = 'hiB' # urllibでhtmlを取得する関数 def get_html(url): htmlfp = urllib.request.urlopen(url) html = htmlfp.read().decode('utf-8', 'replace') htmlfp.close() return html # 編集距離を返す関数 def levenshtein_distance(str1, str2): len1 = len(str1) len2 = len(str2) table = [[0] * (len2 + 1) for __ in range(len1 + 1)] for i in range(len2 + 1): table[0][i] = i for i in range(len1 + 1): table[i][0] = i for i in range(1, len1 + 1): for j in range(1, len2 + 1): insert = table[i - 1][j] + 1 delete = table[i][j - 1] + 1 replace = table[i - 1][j - 1] \ + (0 if str1[i - 1] == str2[j - 1] else 1) table[i][j] = min(insert, delete, replace) return table[-1][-1] # アーティスト名の表記揺れ対策 # アーティスト名一覧が載っているページ all_url = 'http://www41.atwiki.jp/saikouon_dokoda/pages/15.html' # リンクのついているすべてのアーティスト名を取得 all_artists = [] all_html = get_html(all_url) all_soup = BeautifulSoup(all_html, 'lxml') links = all_soup.findAll(href=re.compile('//www41.atwiki.jp/' 'saikouon_dokoda/pages/')) for link in links: all_artists.append(link.text) # 入力した文字列に最も編集距離の近いアーティスト名の出力 for i, artist in enumerate(all_artists): if i == 0: distance = levenshtein_distance(artist, word) true_word = urllib.parse.quote_plus(artist, encoding='sjis') else: if distance > levenshtein_distance(artist, word): distance = levenshtein_distance(artist, word) true_word = urllib.parse.quote_plus(artist, encoding='sjis') print(urllib.parse.unquote_plus(true_word, encoding='sjis')) # アーティスト毎のURL url = 'http://www41.atwiki.jp/saikouon_dokoda/?page={}'.format(true_word) html = get_html(url) # 表部分を抽出 soup = BeautifulSoup(html, 'lxml') trs = soup.findAll('tr') # 各曲の情報をリスト型に格納 matrix = [[]] i = 0 for tr in trs: matrix0 = [] for td in tr.findAll('td'): matrix0.append(td.text) if '\n' in matrix0[0]: matrix0[0] = matrix0[0][1:(len(matrix0[0])-1)] # 「タイトル」などの見出し行は必要ないので削除 if 'タイトル' not in matrix0[0]: matrix.append(matrix0) # 最初の空のリストが不要なので削除 del matrix[0] # 検索 # 音階、オクターブなどの定義(lowlowは基本的に存在しないため、考えない) scales = ['A', 'A#', 'B', 'C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#'] octaves = ['low', 'mid1', 'mid2', 'hi', 'hihi', 'hihihi'] octaves_r = octaves[::-1] scales_r = scales[::-1] # 検索関数 def selecting(phone, txt_name, selecting_name, octaves, scales, row, songlist): f = open(txt_name, 'w') print(urllib.parse.unquote_plus(true_word, encoding='sjis'), selecting_name, sep='\n', file=f) flag = False for octave in octaves: for scale in scales: octave_scale = octave + scale count = 0 for matrix_row in matrix: if octave_scale in matrix_row[row]: if (octave_scale + '#') not in matrix_row[row] \ and ('hi' + octave_scale) not in matrix_row[row]: if count == 0: print('', octave_scale, '', sep='\n', file=f) count += 1 print(' ' + matrix_row[0], file=f) songlist.append(matrix_row[0]) if octave_scale == phone: flag = True break if flag: break del songlist[0] # 地声最高音検索 n_max_list = [''] selecting(natural_max_phone, 'selecting_n_max.txt', 'selecting', octaves, scales, 2, n_max_list) # 地声最低音検索 n_min_list = [''] selecting(natural_min_phone, 'selecting_n_min.txt', 'selecting', octaves_r, scales_r, 1, n_min_list) # 裏声最高音検索 f_max_list = [''] selecting(falsetto_max_phone, 'selecting_f_max.txt', 'selecting', octaves, scales, 4, f_max_list) # 裏声がない曲の救済(裏声が使われない曲は、条件を必ず満たしているとする) for matrix_row in matrix: if not matrix_row[4].strip(): f_max_list.append(matrix_row[0]) # 各リストの共通部分を出力 f = open('result.txt', 'w') print(urllib.parse.unquote_plus(true_word, encoding='sjis'), 'selecting results', 'natural_max: '.format(natural_max_phone), 'natural_min: '.format(natural_min_phone), 'falsetto_max :'.format(falsetto_max_phone), '', sep='\n', file=f) print(set(n_max_list) & set(n_min_list) & set(f_max_list), file=f)
スクレイピング自体の肝は、以下の部分です。
# urllibでhtmlを取得する関数 def get_html(url): htmlfp = urllib.request.urlopen(url) html = htmlfp.read().decode("utf-8", "replace") htmlfp.close() return html # アーティスト毎のURL url = 'http://www41.atwiki.jp/saikouon_dokoda/?page={}'.format(true_word) html = get_html(url) # 表部分を抽出 soup = BeautifulSoup(html, 'lxml') trs = soup.findAll("tr") # 各曲の情報をリスト型に格納 matrix = [[]] i = 0 for tr in trs: matrix0 = [] for td in tr.findAll("td"): matrix0.append(td.text) if "\n" in matrix0[0]: matrix0[0] = matrix0[0][1:(len(matrix0[0])-1)]
format()メソッドを使用して、url内にアーティスト名を挿入しています。
また、Beautifulsoupは、soup.findAllメソッドを使えば、タグやクラスを引数として渡すことによって、指定したクラスのついたタグのみを一括で取得できます。
htmlの改行を表す"\n"を除くのは、出力の見栄えをよくするためです。
問題点・改善案
問題点としては、文字コードの問題か、外国語表記の文字や特殊文字がページやアーティスト名にあった場合に、エラーが起こってしまいます。また、表の列数が一律になっていないページを指定しても、範囲外参照によるエラーが起こってしまいます。
改善点として、htmlに余計な空白が多かったためにif文のinを多用したのですが、空白を自動で取り除いてくれるstrip関数を用いた方が良かったかもしれませんね。