このページでは、Pythonのスクレイピングを駆使して、はてなブックマークの特定のキーワードで検索した時の過去のエントリーをブックマーク数順にCSVにまとめるスクリプトのチュートリアルを公開します。
このプログラムを使えば、下のようにずらーっとブックマーク数順にはてなブックマークのキーワード検索したエントリーをCSVにまとめることができるので、便利です。
それでは、まずは完成したソースコードをお見せします。
import requests # Webページ取得に使用するため from bs4 import BeautifulSoup # スクレイピングするため(備考欄の取得のみ) from urllib.parse import urljoin # 絶対URL取得のため import time # 待ち時間を指定するため import csv # CSVファイルを書き込みするため import re # 文字列抽出のため TITLE = 'はてなブックマーク' def main(): ''' はてなブックマークから情報を収集し、CSVファイルに出力 ''' # 検索キーワードをテキストファイルから読み込む with open('hatena_keyword.txt') as f: keyword_list = [s.rstrip() for s in f.readlines()] # 情報取得処理 for keyword in keyword_list: # キーワード報告 print('キーワード:' + keyword) item_list = [] url = 'https://b.hatena.ne.jp/search/text?q=' + keyword + '&sort=recent&users=50&safe=on' # Webページ取得、パース try: r = requests.get(url) except requests.exceptions.SSLError: r = requests.get(url, verify=False) time.sleep(1) # 1秒待機 soup = BeautifulSoup(r.content, 'lxml') # 指定されたページに遷移しているか確認 title = soup.title.text assert TITLE in title assert keyword in title page_count = 1 while True: # ページ数報告 print(str(page_count) + 'ページ目') # サーバーエラー処理 title = soup.title.text if 'サーバーエラーが発生しました' in title: print('サーバーエラーが発生しました') break items = soup.select('.bookmark-item.js-user-bookmark-item.js-keyboard-selectable-item') for item in items: item_dict = {} # ブックマーク数 bookmarks = item.select('.centerarticle-users > a') # 要素があるか確認 if bookmarks: # Noneではないか確認 if bookmarks[0].text is not None: # 空文字ではないか確認 if bookmarks[0].text.strip(): m = re.search(r'(\d+)', bookmarks[0].text.strip()) # 数字のみ抽出 if m: if m.group(1).isdecimal(): item_dict['ブックマーク数'] = int(m.group(1)) # 数字に変換 else: item_dict['ブックマーク数'] = 0 else: item_dict['ブックマーク数'] = 0 else: item_dict['ブックマーク数'] = 0 else: item_dict['ブックマーク数'] = 0 else: item_dict['ブックマーク数'] = 0 # 投稿日時 dates = item.select('.entry-contents-date') # 要素があるか確認 if dates: # Noneではないか確認 if dates[0].text is not None: # 空文字ではないか確認 if dates[0].text.strip(): item_dict['投稿日時'] = dates[0].text.strip() else: item_dict['投稿日時'] = '' else: item_dict['投稿日時'] = '' else: item_dict['投稿日時'] = '' # タイトル、URL titles = item.select('.centerarticle-entry-title > a') # 要素があるか確認 if titles: # Noneではないか確認 # タイトル if titles[0].text is not None: # 空文字ではないか確認 if titles[0].text.strip(): item_dict['タイトル'] = titles[0].text.strip() else: item_dict['タイトル'] = '' else: item_dict['タイトル'] = '' # URL if titles[0].get('href') is not None: # 空文字ではないか確認 if titles[0].get('href').strip(): item_dict['URL'] = titles[0].get('href').strip() else: item_dict['URL'] = '' else: item_dict['URL'] = '' else: item_dict['タイトル'] = '' item_dict['URL'] = '' # リストに追加 item_list.append(item_dict) # 次ページ遷移処理 nexts = soup.select('.centerarticle-pager-next.js-keyboard-selectable-item > a') # 要素があるか確認 if nexts: # Noneではないか確認 if nexts[0].get('href') is not None: # 空文字ではないか確認 if nexts[0].get('href').strip(): next_url = urljoin(url, nexts[0].get('href').strip()) try: r = requests.get(next_url) except requests.exceptions.SSLError: r = requests.get(next_url, verify=False) time.sleep(1) # 1秒待機 soup = BeautifulSoup(r.content, 'lxml') page_count += 1 else: break else: break else: break # CSVファイルに出力 print('CSV出力') write_csv(item_list, keyword) def write_csv(item_list, keyword): ''' CSVファイルに出力 ''' # 辞書のヘッダーを指定 keys = [ 'ブックマーク数', '投稿日時', 'タイトル', 'URL' ] # 'ブックマーク数'が多い順にソート item_list.sort(key=lambda x: x['ブックマーク数'], reverse=True) with open(keyword + '.csv', 'w') as f: # 上書きモードで開く writer = csv.DictWriter(f, keys) writer.writeheader() for item in item_list: # ブックマーク数が0の場合、空文字に変更 if item['ブックマーク数'] == 0: item['ブックマーク数'] = '' writer.writerow(item) if __name__ == '__main__': # main関数の実行 main()
今回はちょっと複雑に見えますが、基本的には過去に解説したスクレイピングの記事と同じでBeautifulSoupとrequestsをメインで使います。
プログラム実行前の準備
未インストールの追加ライブラリのインストールを行う
もし追加ライブラリのインストールがまだの場合は下記のコマンドを実行してインストールを行って下さい。
pip install requests
pip install beautifulsoup4
また当プログラムではBeautifulSoup4の内部で使うhtmlパーサーにlxmlライブラリを指定しますので、これもインストールがまだの方はインストールを行って下さい。
pip install lxml
検索したいキーワードを羅列したテキストファイルを用意する
プログラムファイルと同じフォルダ内に「hatena_keyword.txt」というファイル名でテキストファイルを用意し、その中に検索したいキーワードを記入しておいて下さい。
キーワードはいくつでも結構です。
ソースコードの解説
ここからはソースコードについて具体的に解説していきます。
プログラムで使うライブラリをインポートする
1~6行目では、今回のプログラムに必要なライブラリをインポートしています。
1行目:
import requests # Webページ取得に使用するため
requestsはPythonでhttp通信を行うための外部ライブラリです。
詳しくはPythonでスクレイピングしてタイトルと見出しを取得するプログラムの記事をご覧下さい。
2行目:
from bs4 import BeautifulSoup # スクレイピングするため(備考欄の取得のみ)
BeautifulSoupはHTMLの構造を解析するためのスクレイピングに特化した外部ライブラリです。
詳しくはPythonでスクレイピングしてタイトルと見出しを取得するプログラムの記事をご覧下さい。
3行目:
from urllib.parse import urljoin # 絶対URL取得のため
urllib.parseはURLの解析に使われるモジュールです。
今回は完全なURL(=絶対URL)を獲得するのに使用するurljoinをインポートしています。
4行目:
import time # 待ち時間を指定するため
待ち時間を指定するsleep関数を使用するためtimeモジュールをインポートしています。
5行目:
import csv # CSVファイルを書き込みするため
CSVファイルを扱うために使用するcsvモジュールをインポートしています。
6行目:
import re # 文字列抽出のため
正規表現を使った文字列の抽出処理に使用するreモジュールをインポートしています。
ページ遷移チェック用変数を用意する
9行目:
TITLE = 'はてなブックマーク'
main関数実行中にてページ遷移の状態を確認するために使用する変数TITLEに値「はてなブックマーク」を設定しています。
main関数を定義する
12~151行はこのプログラムのメイン処理となるmain関数の定義部分となります。
それでは各処理部分について詳細に解説していきます。
17~19行目:
# 検索キーワードをテキストファイルから読み込む with open('hatena_keyword.txt') as f: keyword_list = [s.rstrip() for s in f.readlines()]
検索したいキーワードが記載されている「hatena_keyword.txt」を1行ずつ読み込んで変数keyword_listに格納しています。
ファイルを開くopen()メソッドと共にwith構文が使われています。
openだけで開くとファイルを閉じる処理も記述しなければいけないのですが、with構文を使うとファイルを自動で閉じてくれるので、閉じる操作を記述する手間が省けて便利です。
ここではファイルを1行ずつ取り出す処理を記述するのにリスト内包表記という表記方法が使用されています。リスト内包表記を使うと処理をすっきりと記述することができます。
リスト内包表記は以下のような構造になっています。
リスト(ここではf.readlines())の要素が一つずつ取り出されては変数(ここではs)に格納されていき、その変数sを使って式の部分(ここではs.rstrip())にて必要な処理や評価が行われた結果が最終的に新たなリストとなって返されます。
readlines()はファイル全体をリストとして読み込むメソッドです。
rstrip()は指定した文字列の末尾にある空白文字(改行コードやタブなど)を取り除くのに使うメソッドです。
プログラム内で処理する文字列に余計な空白文字がついたままですと予期せぬエラーの原因になることがありますのでファイルなどから取り出した文字列は処理の前にstripメソッド(strip、lstrip、rstrip)を使って整形しておくと安全です。
21~22行目:
# 情報取得処理 for keyword in keyword_list:
for構文でリスト変数keyword_listから要素を一つずつ取り出し変数keywordに格納していきます。
24行目:
print('キーワード:' + keyword)
print関数で処理対象のキーワードをコマンドプロンプトに表示しています。
26行目:
item_list = []
リスト変数item_listは初期化しておきます。
27行目:
url = 'https://b.hatena.ne.jp/search/text?q=' + keyword + '&sort=recent&users=50&safe=on'
スクレイピングするサイトのURLを変数urlに格納しています。例えば現在検索対象となっているキーワードが「Python」ならば値は
となります。
29~35行目:
# Webページ取得 パース try: r = requests.get(url) except requests.exceptions.SSLError: r = requests.get(url, verify=False) time.sleep(1) # 1秒待機 soup = BeautifulSoup(r.content, 'lxml')
変数urlに格納されているURLにアクセスしてサイトから情報を取得する処理部分になります。
まずはrequests.get()メソッドでレスポンスを取得します。
ここではtry~except文を使って
try: r = requests.get(url) except requests.exceptions.SSLError: r = requests.get(url, verify=False)
と記述してあります。
これは普通にリクエストを送る記述(requests.get(url))にてアクセスするとサイトによってはSSL関係のエラーが出てうまくいかないケースがありますのでそのような場合のエラー回避対策となっています。
SSL関係のエラーが出るサイトにはキーワード引数verifyにFalseを指定することでエラーが出ずにレスポンスを取得することができます。
次にtime.sleep()関数を使って1秒間待機した後取得したレスポンスをBeautifulSoup()メソッドでパースします。
BeautifulSoupで使われるhtmlパーサーについて
BeautifulSoup内部で使われるhtmlパーサーは、特に何も指定しないとデフォルトで「html.parser」が使用されるようになっています。
このままでもよいのですがhtml.parserは性能があまり良くないので第2引数に性能のいい「lxml」を明示的に指定しておくことで処理の高速化が見込めます。
lxml以外には「html5lib」というhtmlパーサーもあります。
37~40行目:
# 指定されたページに遷移しているか確認 title = soup.title.text assert TITLE in title assert keyword in title
ここでは目的のページに正しくアクセスできているかチェックしています。
チェックにはWEBページに含まれているタイトルを利用します。
タイトルはHTMLソース内では<title>タグで挟まれて記述されています。
BeautifulSoupでパースしたWEBページデータが格納されている変数soupに「title.text」をつけることによって<title>タグの文字列を取り出して変数titleに格納しています。
今回スクレイピングしているはてなブックマークでは、タイトルは
(〇〇には検索対象のキーワード名が入ります)
という構造になっています。
そのため
・タイトルに検索キーワードが含まれているか
をチェックすることで正しいページであるかどうかを判断できます。
チェックにはassert文を使います。もしどちらか一つでもタイトル内に含まれていなかったらエラーとなりプログラムがストップするようになっています。
42~45行目:
page_count = 1 while True: # ページ数報告 print(str(page_count) + 'ページ目')
42~147行目は実際にはてなブックマークのページから目的のデータを取り出す処理となります。
まず42行目でページ数をカウントするのに使用する変数page_countを用意しています。
次に43行目のwhile文で条件式に真偽値のTrueを指定してここの処理部分を永久ループとしてあります。
指定したキーワードでの検索結果が全部で何ページあるかは不明なため、後述するbreak文によってループを抜けるまでは処理を何回も繰り返すことを意味します。
45行目のprint関数で現在処理中のはてなブックマークのページ数を表示しています。
47~51行目:
# サーバーエラー処理 title = soup.title.text if 'サーバーエラーが発生しました' in title: print('サーバーエラーが発生しました') break
WEBページに対して繰り返し処理を行っていると、時にはWEBページが使用しているサーバーの不調が原因でサーバーエラーが発生することがあります。
サーバーエラーが発生するとタイトル内に「サーバーエラーが発生しました」という文言が表示されるようになっています。
ですのでここではタイトル内に「サーバーエラーが発生しました」という文字列が含まれていないかをチェックし、もし含まれていたらそこでループ処理を中断して次の処理に移るようにしてあります。
53~55行目:
items = soup.select('.bookmark-item.js-user-bookmark-item.js-keyboard-selectable-item') for item in items: item_dict = {}
53行目はCSSセレクタを使用してページ内から検索結果の各項目が記載されている部分を取り出しています。
<li>タグにclass属性として
が指定されているものが今回対象となる要素になります。
ですのでsoup.select()メソッドの引数に
と指定してそのページに存在する該当要素を全て取得し、変数itemsに格納しています。
それを次の行のfor文で1つずつ取り出して変数itemに格納し、次行以降でitemを使って処理が行われます。
55行目では取り出した情報を辞書形式で格納するために使用する変数item_dictを用意しています。
57~78行目:
# ブックマーク数 bookmarks = item.select('.centerarticle-users > a') # 要素があるか確認 if bookmarks: # Noneではないか確認 if bookmarks[0].text is not None: # 空文字ではないか確認 if bookmarks[0].text.strip(): m = re.search(r'(\d+)', bookmarks[0].text.strip()) # 数字のみ抽出 if m: if m.group(1).isdecimal(): item_dict['ブックマーク数'] = int(m.group(1)) # 数字に変換 else: item_dict['ブックマーク数'] = 0 else: item_dict['ブックマーク数'] = 0 else: item_dict['ブックマーク数'] = 0 else: item_dict['ブックマーク数'] = 0 else: item_dict['ブックマーク数'] = 0
item内に格納されているデータの中からブックマーク数を示している要素部分をCSSセレクタで取り出しています。
該当部分は<span class=”centerarticle-users”>タグの下にある子要素の<a>タグになりますので引数は「.centerarticle-users > a」と指定します。
そうして取り出したブックマーク数を表す要素を変数bookmarksに格納しています。
取り出した要素に含まれるブックマーク数をitem_dictに格納する前に、以下の3点についてチェックします。
・テキスト情報(ブックマーク数が記載されている)自体はちゃんとあるか
・テキスト情報が空文字(スペースなど)でないか
それぞれについて
・if bookmarks[0].text is not None:
・if bookmarks[0].text.strip():
の各if文で判断しています。このうち3つ目につきましては17~19行目のところで解説したstripメソッドを使ってテキスト情報(bookmarks[0].text)の両端にある空白文字を全て除去してから判定しています。
もしテキスト情報に空白文字以外の文字が含まれていなかったらなにも残らないということですので判定結果は偽となります。
3つのチェック項目についてif文の判定結果が全て真の場合のみitem_dictにブックマーク数が格納され、それ以外は数値の0が格納されます。
ブックマーク数を示すテキスト情報ははてなブックマークでは下記のように記載されています
このうち必要なデータは○○の数字の部分だけですので、reモジュールのsearch()メソッドを使って正規表現で数字だけを取り出します
m = re.search(r'(\d+)', bookmarks[0].text.strip())
search()メソッドの第1引数に検索パターンとしてr'(\d+)’を指定しています。正規表現で「\d」は「0~9の半角数字1つ」を表し「+」は「直前の文字の1回以上の繰り返し」を表します。
ですので「\d+」は「1桁以上の半角数字」を表す正規表現となり、これで○○の数字の部分だけを取り出すことができます。
search()メソッドで取り出して変数mに格納された数字(=ブックマーク数)は通常は正常なブックマーク数を表しているのですが、まれに元データの異常などにより数値と認識できない値の場合もありますので取り出した数字データについても
・取り出したデータが全て十進数の数字のみで表されているか
の2点を格納前にチェックします。
それぞれ下記のif文で判断しています。
・if m.group(1).isdecimal():
2つの判定結果について両方とも真の場合のみitem_dictにブックマーク数が格納され、それ以外は数値の0が格納されます。
変数mに格納された数字は一見普通の数値のように見えますが実はプログラム内では「文字列」として認識されるデータ型になっていますので、item_dictに格納する前に整数型に変換しておく必要があります。
文字列型から整数型への変換には下記のようにint関数を使用します。
int(m.group(1))
80~94行目:
# 投稿日時 dates = item.select('.entry-contents-date') # 要素があるか確認 if dates: # Noneではないか確認 if dates[0].text is not None: # 空文字ではないか確認 if dates[0].text.strip(): item_dict['投稿日時'] = dates[0].text.strip() else: item_dict['投稿日時'] = '' else: item_dict['投稿日時'] = '' else: item_dict['投稿日時'] = ''
先のブックマーク数と同様にitem内に格納されているデータの中から投稿日時を示している要素部分をCSSセレクタで取り出し変数datesに格納しています。
CSSセレクタの引数は「.entry-contents-date」となります。
また投稿日時についても
・テキスト情報自体はちゃんとあるか
・テキスト情報が空文字(スペースなど)でないか
のチェック項目3点をif文で判定し、全て真の場合のみitem_dictに投稿日時を格納し、それ以外は空白を格納します。
96~121行目:
# タイトル URL titles = item.select('.centerarticle-entry-title > a') # 要素があるか確認 if titles: # Noneではないか確認 # タイトル if titles[0].text is not None: # 空文字ではないか確認 if titles[0].text.strip(): item_dict['タイトル'] = titles[0].text.strip() else: item_dict['タイトル'] = '' else: item_dict['タイトル'] = '' # URL if titles[0].get('href') is not None: # 空文字ではないか確認 if titles[0].get('href').strip(): item_dict['URL'] = titles[0].get('href').strip() else: item_dict['URL'] = '' else: item_dict['URL'] = '' else: item_dict['タイトル'] = '' item_dict['URL'] = ''
ブックマーク数・投稿日時と同じようにタイトル・URL情報を取り出します。CSSセレクタの引数は「.centerarticle-entry-title > a」となります。
こちらもそれぞれチェック項目3点をif文で判定し、全て真の場合のみitem_dictに格納し、それ以外は空白を格納します。
123~124行目:
# リストに追加 item_list.append(item_dict)
item_dictに格納しておいたブックマーク数・投稿日時・タイトル・URL情報のデータをitem_listに追加していきます。
126~147行目:
# 次ページ遷移処理 nexts = soup.select('.centerarticle-pager-next.js-keyboard-selectable-item > a') # 要素があるか確認 if nexts: # Noneではないか確認 if nexts[0].get('href') is not None: # 空文字ではないか確認 if nexts[0].get('href').strip(): next_url = urljoin(url, nexts[0].get('href').strip()) try: r = requests.get(next_url) except requests.exceptions.SSLError: r = requests.get(next_url, verify=False) time.sleep(1) # 1秒待機 soup = BeautifulSoup(r.content, 'lxml') page_count += 1 else: break else: break else: break
キーワードでの検索結果が複数ページある場合は次のページを読みに行く必要がありますのでここでは次ページに遷移するための処理を行っています。
複数ページが存在する場合はてなブックマークのページ下部に
のようなリンクが表示されています。
このうち「次のページ」というリンクはclass属性に「centerarticle-pager-next js-keyboard-selectable-item」を持つ<span>タグの子要素である<a>タグによって指定されていますのでCSSセレクタで「.centerarticle-pager-next.js-keyboard-selectable-item > a」を指定してタグ情報を取得し変数nextsに格納します。
格納したタグ情報をブックマーク数などの処理時と同様にif文でチェック項目を判断し、全て真の場合のみ次ページ遷移処理を行い、それ以外は次ページが存在しないと見なしてbreak文を使ってwhile文を抜けます。
全て真の場合に行う次ページ遷移処理に使用するURLを134行目のところで用意しています。
next_url = urljoin(url, nexts[0].get('href').strip())
次ページへのリンクを表す<a>タグ内でのhref属性を見ると、下記のようにURLは絶対URLではなく相対URLで指定されています。
そのためurljoin()関数を使って相対URLを絶対URLに変換してから変数next_urlに格納しています。第1引数の基底URLには27行目で用意したurlを指定します。
その次のrequests.get()メソッドでは29~35行目での処理と同じくtry~except文を使ってSSL関係のエラー対策を行っています。
レスポンスを取得した後は1秒間待機してからBeautifulSoup()メソッドでパースを行います。
次ページ遷移処理の最後にpage_countの値を1増やしています。
149~151行目:
# CSVファイルに出力 print('CSV出力') write_csv(item_list, keyword)
main関数の最後にCSVファイルへの出力処理を行っています。
メッセージを表示してから後述するwrite_csv関数を実行しています。
write_csv関数を定義する
154~177行はmain関数で取得したスクレイピングデータをCSVファイルに書き出す処理を行うwrite_csv関数の定義部分となります。
それでは各処理部分について詳細に解説していきます。
159~165行目:
# 辞書のヘッダーを指定 keys = [ 'ブックマーク数', '投稿日時', 'タイトル', 'URL' ]
後述するDictWriter()メソッドでオブジェクト作成時に必要となるヘッダー情報を用意しています。
167~168行目:
# 'ブックマーク数'が多い順にソート item_list.sort(key=lambda x: x['ブックマーク数'], reverse=True)
item_list内のデータを、「ブックマーク数」をソートキーにして降順でソートしています。
キーワード引数keyで使用されているのはラムダ式と呼ばれる無名関数の記述方法です。
とすることでitem_listの要素が引数xに入り、x[‘ブックマーク数’]をキーとして返しています。
今回は降順でソートするのでキーワード引数reverseにはTrueを設定します。
170行目:
with open(keyword + '.csv', 'w') as f: # 上書きモードで開く
スクレイピング結果を出力するCSVファイルのファイル名を「○○.csv(○○はキーワード名)」として上書きモードで開きます。
171行目:
writer = csv.DictWriter(f, keys)
辞書型のデータを書き込むのに使われるDictWriter()を利用してwriterオブジェクトを作成しています。
第2引数には159~165行目で用意したkeysを指定しています。
172行目:
writer.writeheader()
writeheader()メソッドを使用してヘッダーを出力しています。
173~177行目:
for item in item_list: # ブックマーク数が0の場合 空文字に変更 if item['ブックマーク数'] == 0: item['ブックマーク数'] = '' writer.writerow(item)
item_list内のデータを1行ずつ取り出して出力しています。
出力前にブックマーク数をチェックし0の場合は空白に変換しています。
最後にwriterow()メソッドを使用してデータ行を出力しています。
main関数を実行する
180~181行目:
if __name__ == '__main__': # main関数の実行 main()
main関数を実行する処理です。実行条件を
とすることでプログラムが単体で実行された時のみ動作するようにしています。
プログラムを実行していただいたり、改良して@uury112をつけてメンション付きのツイートをしていただければ(https://twitter.com/uuyr112)でリツイートさせていただきます。
コメント