読者です 読者をやめる 読者になる 読者になる

Monthly Hacker's Blog

毎月のテーマに沿ったプログラミング記事を中心に書きます。

Python3でurllib.requestとBeautifulSoupを使って音域データのスクレイピングをしてみる

scraping

はじめに

6月のMonthly Hackのテーマは「スクレイピング」。ということで、自分が普段から見ているWebサイトをスクレイピングすることにしました。

対象とするWebサイトは、音域データ! @ ウィキ ~この曲の最高音はどこ?~です。アーティスト毎に楽曲の地声最高音/最低音、裏声最高音/最低音などが表形式でまとめられています。

私はカラオケで曲を選ぶ際に重宝しているのですが、曲があいうえお順に並んでいるだけであり、「最高音がhiA以下の曲を表示」などの検索機能はありません。したがって、このページをスクレイピングして、自分で設定した条件*1に沿って検索をする事が目標です。

つまり、スクレイピングした情報から更に検索を行います。


本記事では

  • python3でurllib.requestとBeautifulSoupを用いたスクレイピングの方法について解説します。
  • スクレイピングと検索のソースコードを公開します。
  • 問題点や改善の余地について考察します。

大まかな方法

流れは以下の通りです。

  1. 検索したいアーティスト、音域の条件を指定する。
  2. そのアーティストのWebページの全楽曲データを取得する。
  3. 音域の各条件に沿って、検索を行う。
  4. 各条件の検索結果に共通して現れる楽曲を出力する。

使用するモジュール

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関数を用いた方が良かったかもしれませんね。

*1:「地声はhiAまでしか出ない」や「裏声はhiBまでしか出ない」等

*2:python2ではurllib2が有名ですが、python3では使用できないので注意!

*3:二つの文字列がどれほど異なっているかを表す距離。

*4:"natural_max_phone":地声最高音 "natural_min_phone":地声最低音 "falsetto_max_phone":裏声最高音